Skip to content

Commit c936653

Browse files
roed314claude
andcommitted
Show the whole hash-collision cluster, and stop counters answering hash searches (LMFDB#5556)
Follow-up review of PR 44 found the hash search still rendered through gps_groups, so it could not show what the complete tables know. A new route, /Groups/Abstract/hash/<order>/<value>, answers "which groups have this order and hash". At the ten orders with a complete gps_smallhash table it lists every one of them, including those with no database row (78125#3521944227884464685 is 78125.82, 78125.335, 78125.340, and only the last two are in gps_groups), says that the list is complete, links the ones with a homepage and marks the rest as absent, and redirects when the cluster determines a single group. Other orders go on to the ordinary search page, where the stored column is the hash. Every "groups with this order and hash" link now goes through the route: group homepages, subgroup and quotient popups, the identification tool and the find box, as does a search whose only constraints are an order and a hash. The hash column of gps_groups is no longer consulted at the six orders where it holds the label counter, whatever the order constraint: with no single order, hash_constraint builds an $or of the column match away from those orders together with one resolved (order, counter) branch per affected order, from a single indexed gps_smallhash query. So a search for the hash 11 no longer returns 512.11, whose stored 11 is its counter, while a search for 1584677793794603025 finds it with no order given at all. The optional column still cannot show a hash that only gps_smallhash knows, which is now documented; where the search itself named one hash at one order, that value is displayed. Out-of-range values are refused by the new route rather than reaching the database, which errors on them. Verified with sage -python -m pytest on lmfdb/groups/abstract: test_abstract_groups.py 25 passed (the hash tests now cover the complete cluster page, the popup and identification links into it, the label with no homepage, and the false-positive cases), test_browse_page.py 71 passed, pyflakes clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 490df40 commit c936653

4 files changed

Lines changed: 304 additions & 56 deletions

File tree

lmfdb/groups/abstract/hash_lookup.py

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,21 @@
2424
A hash search at one of those ten orders therefore has to go to
2525
``gps_smallhash``, and what comes back is complete: the groups it lists are
2626
provably all the groups of that order with that hash, so a unique match is a
27-
proof of isomorphism.
27+
proof of isomorphism, and a collision is a cluster that a search of
28+
``gps_groups`` alone would under-report. That is what the ``by_hash`` route
29+
renders; :func:`hash_search_url` sends every "groups with this order and hash"
30+
link there.
2831
2932
Index note: ``gps_smallhash`` has 4.2*10^8 rows and a single index, on
3033
``(order, hash)``. Queries in that direction are fast. The other direction,
3134
``(order, counter)`` to a hash, is a sequential scan (about six minutes against
32-
devmirror), so the true hash of a group at one of these orders is not something
33-
a page can display; :func:`structural_hash` reports that there is no usable
34-
value rather than showing the counter under a "hash" heading.
35+
devmirror), so a group at one of the six counter-storing orders cannot be asked
36+
for its own hash. A row of such an order therefore shows no hash on its
37+
homepage and an empty cell in the optional search column, rather than its
38+
counter under a "hash" heading (:func:`structural_hash`); the exception is a
39+
search that named one hash at one order, where :func:`searched_hash` recovers
40+
the value from the query itself. Displaying these hashes in general would take
41+
an ``(order, counter)`` index on ``gps_smallhash``.
3542
"""
3643

3744
from dataclasses import dataclass, field
@@ -48,6 +55,10 @@
4855
[512, 1152, 1536, 1920, 2187, 6561, 15625, 16807, 78125, 161051]
4956
)
5057

58+
# The orders where gps_groups.hash is the label counter rather than the hash,
59+
# so that a search against that column would be answering a different question.
60+
COUNTER_HASH_ORDERS = frozenset([512, 1152, 1536, 1920, 2187, 15625])
61+
5162

5263
@dataclass(frozen=True)
5364
class HashResolution:
@@ -101,6 +112,17 @@ def smallhash_counters(order, values):
101112
{"order": int(order), "hash": {"$in": [int(v) for v in values]}}, "counter")})
102113

103114

115+
def smallhash_counters_by_order(orders, values):
116+
"""The counters of the groups of any of these ``orders`` whose hash is one
117+
of ``values``, as a dict keyed by order. One indexed query."""
118+
out = {}
119+
for rec in db.gps_smallhash.search(
120+
{"order": {"$in": [int(N) for N in orders]},
121+
"hash": {"$in": [int(v) for v in values]}}, ["order", "counter"]):
122+
out.setdefault(int(rec["order"]), []).append(int(rec["counter"]))
123+
return {N: sorted(counters) for N, counters in out.items()}
124+
125+
104126
def resolve_order_hash(order, value):
105127
"""The groups of order ``order`` whose hash is ``value``, as a
106128
:class:`HashResolution`."""
@@ -113,20 +135,49 @@ def resolve_order_hash(order, value):
113135

114136

115137
def hash_constraint(order, values, qfield="hash"):
116-
"""Search constraints selecting the groups of order ``order`` whose hash is
117-
one of ``values``, to be merged into a ``gps_groups`` query.
118-
119-
``order`` may be ``None`` when the order is unconstrained or constrained to
120-
a range, in which case the stored ``hash`` column is the only thing to go
121-
on: such a search can pick up a spurious row at one of the orders that
122-
store counters there, which giving a single order (or using the ``N#h``
123-
form) avoids. With an order in hand we resolve the hashes to counters at
124-
the complete-table orders, since ``gps_groups.hash`` is not the hash there.
138+
"""Search constraints selecting the groups whose hash is one of ``values``,
139+
to be merged into a ``gps_groups`` query.
140+
141+
``order`` is the single order the search is pinned to, or ``None`` when the
142+
order is unconstrained or constrained to a range or a list. Either way the
143+
stored ``hash`` column is only consulted at the orders where it holds the
144+
hash: at the others the hashes are resolved to counters through
145+
``gps_smallhash``, so that a search for the hash ``11`` cannot come back
146+
with ``512.11``, whose stored 11 is its counter.
147+
148+
With no single order this needs a top-level ``$or`` (merge it with
149+
``collapse_ors``, not ``dict.update``); any order constraint already in the
150+
query still applies to every branch.
125151
"""
126152
values = [int(v) for v in values]
127-
if order is not None and int(order) in SMALLHASH_ORDERS:
128-
return {"counter": {"$in": smallhash_counters(order, values)}}
129-
return {qfield: values[0] if len(values) == 1 else {"$or": values}}
153+
column = {qfield: values[0] if len(values) == 1 else {"$or": values}}
154+
if order is not None:
155+
if int(order) in SMALLHASH_ORDERS:
156+
return {"counter": {"$in": smallhash_counters(order, values)}}
157+
return column
158+
counters = smallhash_counters_by_order(COUNTER_HASH_ORDERS, values)
159+
branches = [dict(column, order={"$nin": sorted(COUNTER_HASH_ORDERS)})]
160+
branches.extend({"order": N, "counter": {"$in": cs}} for N, cs in sorted(counters.items()))
161+
return {"$or": branches}
162+
163+
164+
def searched_hash(info):
165+
"""The ``(order, value)`` that a search asked for, when it asked for one
166+
hash at one order and so knows a value that the row itself may not record.
167+
168+
Both ``N#h`` in the hash box and a hash with the order box pinned to a
169+
single order count; a list of hashes or a range of orders does not.
170+
"""
171+
raw = (info.get("hash") or "").strip()
172+
if not raw or "," in raw:
173+
return None
174+
try:
175+
if raw.count("#") == 1:
176+
order, value = raw.split("#")
177+
return int(order), int(value)
178+
return int((info.get("order") or "").strip()), int(raw)
179+
except ValueError:
180+
return None
130181

131182

132183
def structural_hash(counter, stored):
@@ -151,5 +202,10 @@ def order_search_url(order):
151202

152203

153204
def hash_search_url(order, value):
154-
"""URL of the search page listing all groups of the given order and hash."""
155-
return url_for("abstract.index", hash=f"{int(order)}#{int(value)}")
205+
"""URL of the page listing all the groups of the given order and hash.
206+
207+
Every "groups with this order and hash" link goes here, and the route sends
208+
the orders without a complete table on to the ordinary search page, so that
209+
such a link never shows a subset of what it promises.
210+
"""
211+
return url_for("abstract.by_hash", order=int(order), value=int(value))

lmfdb/groups/abstract/main.py

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,13 @@
7878
looks_like_permutation,
7979
)
8080
from .hash_lookup import (
81+
SMALLHASH_ORDERS,
8182
hash_constraint,
8283
hash_search_url,
84+
live_pages_available,
8385
order_search_url,
8486
resolve_order_hash,
87+
searched_hash,
8588
structural_hash,
8689
)
8790

@@ -243,26 +246,30 @@ def parse_family(inp, query, qfield):
243246

244247
@search_parser
245248
def parse_hashes(inp, query, qfield, order_field):
246-
# hash_constraint decides which column carries the hash at this order: at
247-
# the orders with a complete gps_smallhash table, gps_groups.hash holds the
248-
# label counter instead of the hash, so the search has to go through
249-
# gps_smallhash and come back as a list of counters.
249+
# hash_constraint decides which column carries the hash: at the orders with
250+
# a complete gps_smallhash table, gps_groups.hash holds the label counter
251+
# instead of the hash, so those orders are resolved through gps_smallhash
252+
# and come back as a list of counters (as a $or over orders when the search
253+
# is not pinned to one order).
250254
if inp.count("#") == 0:
251255
opts = [ZZ(opt) for opt in inp.split(",")]
252256
N = query.get(order_field)
253257
if isinstance(N, (dict, list)):
254-
N = None # a range or list of orders: only the stored column to go on
255-
query.update(hash_constraint(N, opts, qfield))
258+
N = None # a range or list of orders: constrain each order separately
259+
constraint = hash_constraint(N, opts, qfield)
256260
elif inp.count("#") == 1:
257261
N, hsh = inp.split("#")
258262
N, hsh = ZZ(N), ZZ(hsh)
259263
if order_field not in query:
260264
query[order_field] = N
261265
elif query[order_field] != N:
262266
raise ValueError(f"You cannot specify order both in the {order_field} input and the {qfield} input")
263-
query.update(hash_constraint(N, [hsh], qfield))
267+
constraint = hash_constraint(N, [hsh], qfield)
264268
else:
265269
raise ValueError("To specify multiple hash values, all must have the same order; provide the order in the order input and then just give hashes separated by commas")
270+
if "$or" in constraint:
271+
collapse_ors(["$or", constraint.pop("$or")], query)
272+
query.update(constraint)
266273

267274
#input string of complex character label and return rational character label
268275
def q_char(char):
@@ -798,6 +805,12 @@ def index():
798805
search_types = request.args.getlist("search_type")
799806
info["search_type"] = search_type = search_types[-1] if search_types else info.get("hst", "")
800807
if search_type in ["List", "", "Random", "Diagram"]:
808+
# A bare order-and-hash search is answered by the hash page, which
809+
# can list the groups of a complete-table order that gps_groups
810+
# does not contain.
811+
asked = hash_only_search(request.args) if search_type in ["List", ""] else None
812+
if asked is not None and asked[0] in SMALLHASH_ORDERS:
813+
return redirect(url_for(".by_hash", order=asked[0], value=asked[1]))
801814
return group_search(info)
802815
# Preserve old abstract-group search URLs while directing users to the
803816
# new, object-specific landing pages. Keep Random* as a search type so
@@ -1253,7 +1266,7 @@ def group_jump(info):
12531266
label = res.unique_label()
12541267
if label is not None:
12551268
return redirect(url_for(".by_label", label=label))
1256-
return redirect(url_for(".index", hash=jump))
1269+
return redirect(hash_search_url(N, hsh))
12571270
# by permutation generators
12581271
if looks_like_permutation(jump):
12591272
return redirect(url_for(".identify_group_page", description=jump))
@@ -1487,8 +1500,12 @@ def group_postprocess(res, info, query):
14871500
if label is not None:
14881501
labels.add(label)
14891502
tex_cache = {rec["label"]: rec["tex_name"] for rec in db.gps_groups.search({"label":{"$in":list(labels)}}, ["label", "tex_name"])}
1503+
# Where gps_groups.hash holds the label counter the row cannot supply its
1504+
# own hash (see hash_lookup), but a search for one hash at one order can.
1505+
asked = searched_hash(info)
14901506
for rec in res:
14911507
rec["tex_cache"] = tex_cache
1508+
rec["public_hash"] = asked[1] if asked and rec.get("order") == asked[0] else None
14921509
if "family" in info:
14931510
family = info["family"]
14941511
if family == "any":
@@ -1585,12 +1602,13 @@ def group_postprocess(res, info, query):
15851602
["abelian", "nilpotent", "solvable", "smith_abelian_invariants", "nilpotency_class", "derived_length", "composition_length"],
15861603
show_type,
15871604
align="center"),
1588-
# structural_hash suppresses the rows where gps_groups.hash is the label
1589-
# counter rather than the hash, so that this column never shows something
1590-
# other than the value the group.hash knowl describes.
1605+
# The hash the group.hash knowl describes: the stored value where that is
1606+
# the hash, the searched-for value where the search supplies one, and
1607+
# nothing rather than a label counter (see hash_lookup.structural_hash).
15911608
MultiProcessedCol("hash", "group.hash", "Hash",
1592-
["counter", "hash"],
1593-
lambda counter, h: "" if structural_hash(counter, h) is None else str(h),
1609+
["counter", "hash", "public_hash"],
1610+
lambda counter, h, asked: str(asked) if asked is not None
1611+
else ("" if structural_hash(counter, h) is None else str(h)),
15941612
default=False, align="right", short_title="hash")])
15951613

15961614
@search_wrap(
@@ -2700,6 +2718,55 @@ def download_trivial_construction(dltype): #trival gp construction is different
27002718
return s
27012719

27022720

2721+
@abstract_page.route("/hash/<int:order>/<int:value>")
2722+
def by_hash(order, value):
2723+
"""All the groups of a given order and hash.
2724+
2725+
At the orders with a complete ``gps_smallhash`` table this lists every such
2726+
group, including the ones with no database row, which an ordinary search of
2727+
``gps_groups`` cannot do; the other orders go on to the search page, where
2728+
the stored column is the hash and the usual search machinery applies.
2729+
"""
2730+
# Orders and hashes are stored as 32- and 64-bit integers, so anything
2731+
# larger is not one, and asking the database would be an error.
2732+
if order >= 2**31 or value >= 2**63:
2733+
flash_error("No group has order %s and hash %s.", order, value)
2734+
return redirect(url_for(".index"))
2735+
res = resolve_order_hash(order, value)
2736+
if not res.complete:
2737+
return redirect(url_for(".index", hash=f"{order}#{value}", search_type="List"))
2738+
label = res.unique_label()
2739+
if label is not None:
2740+
return redirect(url_for(".by_label", label=label))
2741+
present = set(res.in_lmfdb())
2742+
live = live_pages_available(order)
2743+
return render_template(
2744+
"abstract-hash.html",
2745+
title=f"Groups of order {order} with hash {value}",
2746+
bread=get_bread([("Hash", "")]),
2747+
order=order,
2748+
value=value,
2749+
candidates=[{"label": lab, "present": lab in present, "live": live}
2750+
for lab in res.labels],
2751+
order_url=order_search_url(order),
2752+
learnmore=learnmore_list(),
2753+
)
2754+
2755+
2756+
# Arguments that say how to display a search rather than what to search for.
2757+
HASH_SEARCH_ARGS = {"hash", "order", "search_type", "hst", "count", "start",
2758+
"showcol", "hidecol", "sort_order", "sorts", "columns", "submit"}
2759+
2760+
2761+
def hash_only_search(args):
2762+
"""The ``(order, value)`` of a request that asks for nothing but an order
2763+
and a hash, so that it can be answered by :func:`by_hash` instead of by a
2764+
search of ``gps_groups``; ``None`` for anything else."""
2765+
if any(v.strip() for k, v in args.items() if k not in HASH_SEARCH_ARGS):
2766+
return None
2767+
return searched_hash(args)
2768+
2769+
27032770
@abstract_page.route("/identify")
27042771
def identify_group_page():
27052772
description = request.args.get("description", "")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{% extends "homepage.html" %}
2+
3+
{% block content %}
4+
5+
<p>
6+
{{KNOWL('group.hash', 'Hash')}}: {{ value }}, order&nbsp;{{ order }}
7+
(<a href="{{ order_url }}">all groups of order&nbsp;{{ order }}</a>).
8+
</p>
9+
10+
{% if candidates %}
11+
<p>
12+
The hash tables are complete for order&nbsp;{{ order }}, so these are
13+
{% if candidates|length == 1 %}
14+
all the groups of order&nbsp;{{ order }} with this hash.
15+
{% else %}
16+
all {{ candidates|length }} groups of order&nbsp;{{ order }} with this hash:
17+
they form a hash collision, and any group with this order and hash is
18+
isomorphic to exactly one of them.
19+
{% endif %}
20+
</p>
21+
<ul>
22+
{% for c in candidates %}
23+
<li>
24+
{% if c.present or c.live %}
25+
<a href="{{ url_for('.by_label', label=c.label) }}">{{ c.label }}</a>
26+
{%- if not c.present %} <span class="nowrap">(not in the database)</span>{% endif %}
27+
{% else %}
28+
{{ c.label }} <span class="nowrap">(not in the database)</span>
29+
{% endif %}
30+
</li>
31+
{% endfor %}
32+
</ul>
33+
{% else %}
34+
<p>
35+
The hash tables are complete for order&nbsp;{{ order }}, so no group of
36+
order&nbsp;{{ order }} has this hash.
37+
</p>
38+
{% endif %}
39+
40+
{% endblock %}

0 commit comments

Comments
 (0)