Skip to content

Add Gauss-sum portraits to Dirichlet character pages (#3996) - #38

Closed
roed-math wants to merge 3 commits into
mainfrom
ai/t34-char-portraits
Closed

Add Gauss-sum portraits to Dirichlet character pages (#3996)#38
roed-math wants to merge 3 commits into
mainfrom
ai/t34-char-portraits

Conversation

@roed-math

@roed-math roed-math commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Adds a "portrait" visualization to each individual Dirichlet character homepage, in the properties
box alongside the other basic data, following the design proposed in the issue (Alex Best's demo).
For a character chi of modulus N, and every residue a, the partial Gauss sums
S_a(k) = sum_{n<=k} chi(n) e(a n / N) are drawn as rainbow radial segments (early terms darkened),
the complete Gauss sums tau_a(chi) as dots, and a grey circle of radius sqrt(N); the coprime dots
lie on the circle iff chi is primitive, and rotational/reflective symmetry reveals the order and
reality of the character. The plot is a new self-contained module lmfdb/characters/portraits.py
that renders all segments as a single matplotlib LineCollection and embeds the result via
encode_plot (the elliptic-curve pattern). The only hook into existing code is a one-line call in
render_Dirichletwebpage.

The picture has N * phi(N) segments, and that count (not the modulus) is what its cost scales
with, so that is what is capped: portraits are drawn when N * phi(N) <= 25000, which keeps
N = 300 at about 0.1s while dropping prime-like cases such as N = 293, whose 85556 segments take
some eight times longer. The check happens before any character is constructed or any array
allocated, and is exposed as portrait_complexity / portrait_is_enabled so the policy can be
tested without rendering. Completed portraits are kept in a bounded lru_cache(maxsize=64), so a
repeat visit costs microseconds; a modulus with no portrait is a quiet no-op that never reaches the
cache, and any failure while building one is logged and swallowed, since the picture must never
break the page.

A new /Character/Dirichlet/Pictures page and a "Picture description" entry in the Learn more box
explain what the picture shows, as for number fields and Galois groups, and the image carries a
stable class="dirichlet-character-portrait" plus alt text. That page renders the knowl
portrait.character.dirichlet, which still needs to be created in the knowl database (draft text in
a comment below).

Tested: the complete Gauss sums agree with pari's znchargauss to machine precision for primitive,
imprimitive, real, non-real and composite-modulus characters; |tau_a| = sqrt(N) on units and
tau_a = 0 off them for a primitive character; the N = 1 special case; the workload cutoff on the
non-monotone 293/300 pair; the cache; and the portrait's own CSS class on the character page.
Addresses LMFDB#3996.

🤖 Generated with Claude Code

New self-contained module lmfdb/characters/portraits.py draws, for each
residue a mod N, the partial Gauss sums S_a(k) as rainbow radial segments
(early terms darkened), the complete Gauss sums as dots, and a circle of
radius sqrt(N), following Alex Best's demo from the issue; rendered as a
single matplotlib LineCollection and embedded via encode_plot in the
properties box (the elliptic curve pattern). Computed on the fly for
modulus up to 300, skipped above. Hook is a single 4-line call in
render_Dirichletwebpage; any portrait failure is logged and swallowed.

Verified: complete sums match pari znchargauss to 1e-13 for ~18
odd/even/primitive/imprimitive/trivial characters, |tau_a|=sqrt(N) for
primitive chi at coprime a, 27.8 matches the issue's demo image; pages
checked via flask test client for N=1..300 and skip for N>300;
DirichletCharactersTest (17 tests incl. new test_portrait) green;
pyflakes clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rvisser7

rvisser7 commented Jul 28, 2026

Copy link
Copy Markdown

This looks really cool, many thanks for adding this! The portraits all look good to me, it seems to pretty accurately recreate the pictures given in https://alexjbest.github.io/dirich/ .

I think the PORTRAIT_MAX_MODULUS = 300 cutoff is reasonable, though it might be worth having a lower cutoff, if we want to keep the pages loading fast. For me, $N = 293$ already takes a few seconds to load (e.g. http://localhost:37777/Character/Dirichlet/293/17 ), but it's not too bad.

Maybe we can cache the portraits? Since the runtime depends on $N \cdot \phi(N)$, perhaps it might be better to bound $N \cdot \phi(N)$ instead of $N$.

Maybe one can just add a description of the portraits ("Picture description") in the Learn more box, as done for number fields and Galois groups ? One optional thing to add would be pictures also for orbits of Dirichlet characters (though certainly no need to add it for this PR).

roed314 and others added 2 commits August 4, 2026 17:23
Portraits were guarded by a modulus cutoff, but their cost scales with the
number of segments drawn, N*phi(N), which is far from monotone in N: the
prime 293 draws 85556 of them and takes some eight times as long as the
larger 300, whose phi is only 80, and a reviewer measured seconds of latency
on the 293.17 page. The cutoff is now on the segment count (25000, which
keeps 300 and drops 293), consulted before any character is constructed or
any array allocated, and exposed as portrait_complexity/portrait_is_enabled
so the policy can be tested without rendering. Completed portraits are kept
in a bounded lru_cache(maxsize=64), so repeat visits and crawlers cost
microseconds; moduli with no portrait are rejected before the cache, and a
failure is not cached.

Adds the accessibility and explanatory pieces asked for in review: a stable
dirichlet-character-portrait class and alt text on the image, a
/Character/Dirichlet/Pictures page rendering the knowl
portrait.character.dirichlet, and a "Picture description" entry in the Learn
more box, following the number field and Galois group pattern. The knowl
itself still has to be created in the knowl database.

Also corrects the module docstring, which said every k = 1, ..., N-1 was
drawn: only the k coprime to N are, since chi(n) = 0 off the units makes the
other stages repeat the segment before them.

Tests: complete sums against pari znchargauss for primitive, imprimitive,
non-real and composite-modulus characters; |tau_a| = sqrt(N) on units and 0
off them for a primitive character; the N = 1 point data; the 293/300
workload pair; the cache; and the portrait's own class on the character
page, rather than the presence of any base64 image. Rendered portraits are
byte-identical to before. lmfdb/characters/test_characters.py green (32),
pyflakes/pylint/ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Thanks for the review! Pushed 547265f, which takes up all four points.

The cutoff is now on work, not modulus

You were right that N * phi(N) is the thing to bound. portrait_complexity(N) = N * euler_phi(N)
and portrait_is_enabled(N) now gate the drawing, with PORTRAIT_MAX_SEGMENTS = 25000 alongside
the old PORTRAIT_MAX_MODULUS = 300. That keeps the N = 300 example and drops 293. The check runs
before a ConreyCharacter is built or numpy is touched, so a declined portrait costs nothing.

Timings on this machine, paint_portrait alone (cold cache):

N segments time
1 1 0.011s
4 8 0.010s
27 486 0.017s
300 24000 0.111s
293 85556 0.873s, now skipped

283 and 271 sit at 0.58s and 0.48s, also skipped. Your "few seconds" for 293.17 is consistent: the
same 8x gap over N = 300 shows up here, just on faster hardware and outside debug mode. If 0.1s
still feels like too much to spend in the request, the budget is one constant to lower.

Caching

Completed portraits go through a bounded lru_cache(maxsize=64) keyed by (modulus, number), so a
warm hit is ~20 microseconds instead of 0.1s. Only the finished data URI is cached, not the numpy
arrays; declined moduli are rejected before the cache so they cannot evict real portraits; and an
exception is not cached, so a transient failure does not stick. paint_portrait.cache_clear() and
.cache_info() are available for tests.

A separate cacheable PNG endpoint would be better still (the browser could cache the image, and the
HTML would not carry a duplicated data URI), but that is a bigger change and I have left it out of
this PR.

Picture description

There is now a /Character/Dirichlet/Pictures page and a "Picture description" entry in the Learn
more box, following the number field and Galois group pattern, and the image carries
class="dirichlet-character-portrait" and alt text. Two things need your call:

  1. The knowl does not exist yet. The page renders portrait.character.dirichlet, matching the
    portrait.gg / portrait.maass / portrait.modcurve convention, but I cannot write to the
    knowl database. Until it is created the page renders with an empty body. Draft content below;
    happy to change the id if you would rather have something else.
  2. The Learn more entry is section-wide, since it goes in learn(). That puts it on the group
    and orbit pages too, which have no picture. Easy to narrow to the individual character page if
    you prefer.
Draft knowl: portrait.character.dirichlet, "Pictures for Dirichlet characters"
For each {{ KNOWL('character.dirichlet', 'Dirichlet character') }} $\chi$ of {{ KNOWL('character.dirichlet.modulus', 'modulus') }} $N$ and each residue $a$ modulo $N$, we draw the partial {{ KNOWL('character.dirichlet.gauss_sum', 'Gauss sums') }}
$$ S_a(k) = \sum_{n = 1}^{k} \chi(n) e^{2\pi i a n/N} $$
as segments from the origin of the complex plane to $S_a(k)$. All the segments belonging to a given $a$ share a hue, running through the spectrum from red at $a = 0$ to violet at $a = N-1$; within one hue the segments for small $k$ are darkened and those for large $k$ are drawn at full brightness, so the eye can follow the partial sums as they accumulate. A large dot marks the last of them, the complete Gauss sum $\tau_a(\chi) = S_a(N-1)$.

Only the stages $k$ coprime to $N$ are drawn: $\chi(n) = 0$ whenever $\gcd(n, N) > 1$, so each omitted stage would simply repeat the segment before it.

The grey circle has radius $\sqrt N$. If $\chi$ is {{ KNOWL('character.dirichlet.primitive', 'primitive') }}, then $|\tau_a(\chi)| = \sqrt N$ for every $a$ coprime to $N$, and $\tau_a(\chi) = 0$ for every other $a$: the picture of a primitive character is $\phi(N)$ dots on the circle together with a cluster of dots at the origin, and a character is primitive exactly when its coprime dots reach the circle.

Several other properties of $\chi$ can be read off the picture.

- Since $\tau_a(\chi) = \overline{\chi(a)}\,\tau_1(\chi)$ for primitive $\chi$ and $a$ coprime to $N$, the dots on the circle are the vertices of a regular $n$-gon, where $n$ is the {{ KNOWL('character.dirichlet.order', 'order') }} of $\chi$.
- $S_{N-a}(k) = \overline{S_a(k)}$ when $\chi$ is real, so the picture of a real character is symmetric about the real axis.
- The {{ KNOWL('character.dirichlet.principal', 'principal character') }} shows a single long red spike along the positive real axis, of length $\phi(N)$, since then $S_0(k)$ just counts the units up to $k$.

The pictures are drawn when the page is requested rather than stored in the database, so they are only shown when there is little work to do: the number of segments is $N\phi(N)$, and we draw the picture only when that is at most $25000$. This is why a character of modulus $300$ has a picture while one of modulus $293$ does not.

The design follows [Alex Best's demo](https://alexjbest.github.io/dirich/).

Also

  • The module docstring claimed every k = 1, ..., N-1 was drawn. Only the k coprime to N are,
    since chi(n) = 0 off the units makes the other stages repeat the segment before them. Drawing
    them would multiply the cost by N / phi(N) for an identical picture, so I documented the
    optimization rather than undoing it. Say the word if you want exact reproduction of every repeated
    stage.
  • New unit tests in test_characters.py: complete sums against pari's znchargauss for 4.3, 5.2,
    15.4 (imprimitive) and 12.11 (composite, 8 nonunits); |tau_a| = sqrt(N) on units and tau_a = 0
    off them for the primitive 27.2; the N = 1 point data; the 293/300 workload pair; and the cache.
    The page test now asserts the portrait's own class rather than any base64 image, and the skip path
    is checked through portrait_properties instead of a second request for 40487.5.
  • Rendered portraits are byte-identical to the previous revision, so the visual design is unchanged.
  • lmfdb/characters/test_characters.py is green (32 tests), and pyflakes / pylint / ruff are clean.

Orbit portraits are still out of scope here.

@roed314

roed314 commented Aug 5, 2026

Copy link
Copy Markdown

I added the knowl; GPT signed off.

@roed-math

Copy link
Copy Markdown
Owner Author

Superseded by LMFDB#7172, opened upstream from this same branch. Closing here; review continues upstream. (The draft portrait.character.dirichlet knowl text in this thread stays the reference.)

@roed-math roed-math closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants