2  Quickstart

harmonica is a Clojure library for computational group theory and representation theory.

Groups are the mathematics of symmetry. When a problem has symmetry, group theory turns brute force into elegant formulas. This page shows four examples — each solved in a few lines.

(ns harmonica-book.quickstart
  (:require
   [scicloj.harmonica :as hm]
   [scicloj.harmonica.linalg.complex :as cx]
   [tablecloth.api :as tc]
   [scicloj.tableplot.v1.plotly :as plotly]
   [tech.v3.datatype :as dtype]
   [tech.v3.datatype.functional :as dfn]
   [scicloj.kindly.v4.kind :as kind]))

Counting necklaces

With 8 beads and 3 colors, there are \(3^8 = 6561\) possible colorings. But many are just rotations of each other. How many are truly distinct?

The cyclic group \(C_8\) captures rotational symmetry. Its cycle index encodes the structure of all 8 rotations, and Pólya’s theorem evaluates it — no enumeration needed.

(defn rotation-action [n]
  (fn [g x] (mod (+ x g) n)))
(let [G (hm/cyclic-group 8)
      ci (hm/cycle-index G (rotation-action 8) (range 8))]
  (hm/polya-count ci 3))
834N

If flipping the necklace over also counts as “the same,” the symmetry group is the dihedral group \(D_8\) (rotations + reflections):

(defn dihedral-action [n]
  (fn [[t k] x]
    (case t
      :r (mod (+ x k) n)
      :s (mod (- k x) n))))
(let [G (hm/dihedral-group 8)
      ci (hm/cycle-index G (dihedral-action 8) (range 8))]
  (hm/polya-count ci 3))
498N

834 necklaces, 498 bracelets — from the group’s structure alone. This works even for \(n = 100\) where enumeration is impossible. For the full story, see Counting Necklaces.

The DFT you already know

The Discrete Fourier Transform — behind MP3, JPEG, and spectral analysis — is the Fourier transform on the cyclic group \(\mathbb{Z}/n\mathbb{Z}\). The character table is the DFT matrix.

(def G (hm/cyclic-group 24))
(def ct (hm/character-table G))

Monthly temperatures (°C) over two years:

(def temperatures
  [2 3 7 12 17 22 25 24 19 13 7 3
   3 4 8 13 18 23 26 25 20 14 8 4])
(-> (tc/dataset {:month (range 24) :temp temperatures})
    (plotly/base {:=x :month :=y :temp
                  :=title "Monthly temperatures (°C)"
                  :=x-title "month" :=y-title "°C"})
    (plotly/layer-line)
    (plotly/layer-point {:=mark-size 5})
    plotly/plot)
(def f-hat (hm/fourier-transform ct (cx/complex-tensor-real temperatures)))
f-hat
#ComplexTensor [24]
[320.0, -1.0000000000000484+7.5957541127251424i, -137.2820323027551+7.464101615137718i, -1.0000000000000213+2.414213562373079i, 5.999999999999962-6.928203230275513i, -1.000000000000032+1.3032253728412102i, -2.000000000000009+1.9999999999999902i, -1.0000000000000342+0.7673269879789584i, 1.9999999999999751-3.108624468950438E-14i, -1.0000000000000244+0.41421356237308116i, 1.2820323027550629+0.5358983848622507i, -1.000000000000047+0.1316524975873985i, -1.9594348786357652E-14i, -1.0000000000000444-0.13165249758739717i, 1.2820323027550633-0.5358983848622372i, -1.0000000000000246-0.41421356237311135i, 1.999999999999976-4.218847493575595E-14i, -1.000000000000035-0.7673269879789588i, -2.0000000000000075-2.0000000000000098i, -1.0000000000000284-1.3032253728412089i, ... (24 total)]

The Fourier magnitudes reveal which frequencies carry the signal’s energy:

(let [n 24
      magnitudes (mapv (fn [k] {:k k :magnitude (cx/cabs (f-hat k))})
                       (range (inc (/ n 2))))]
  (-> (tc/dataset magnitudes)
      (plotly/base {:=x :k :=y :magnitude})
      (plotly/layer-bar)
      (plotly/update-data
       (fn [d] (assoc d :=layout
                      {:title "Fourier magnitudes |f̂(k)|"
                       :xaxis {:title "frequency k" :dtick 1}
                       :yaxis {:title "|f̂(k)|"}
                       :width 500 :height 300})))
      plotly/plot))

The DC component (\(k = 0\)) is the sum of all values. The dominant non-DC component is \(k = 2\) — two cycles in 24 months, the annual cycle:

(cx/re (f-hat 0))
320.0

Verify \(k = 2\) dominates: its magnitude exceeds all other non-DC components.

(let [mags (mapv (fn [k] [k (cx/cabs (f-hat k))]) (range 1 (inc (/ 24 2))))]
  (first (apply max-key second mags)))
2

Inverse transform recovers the original signal exactly:

(let [recovered (cx/re (hm/inverse-fourier-transform ct f-hat))]
  (< (dfn/reduce-max (dfn/abs (dfn/- recovered temperatures))) 1e-10))
true

This generalizes: the same framework (group → character table → Fourier transform) works for any finite group. For the full story, see The DFT as Group Fourier Transform.

Symmetry you can see

Draw one curve. Apply the dihedral group \(D_6\) — rotations and reflections of a hexagon — and get a snowflake.

(defn make-rosette [n motif]
  (let [matrices
        (mapcat
         (fn [k]
           (let [a (* 2.0 Math/PI (/ (double k) (double n)))]
             [[[(Math/cos a) (- (Math/sin a))]
               [(Math/sin a) (Math/cos a)]]
              [[(Math/cos a) (Math/sin a)]
               [(Math/sin a) (- (Math/cos a))]]]))
         (range n))]
    (mapv (fn [[[a b] [c d]]]
            (mapv (fn [[x y]] [(+ (* a x) (* b y))
                               (+ (* c x) (* d y))])
                  motif))
          matrices)))
(let [motif (mapv (fn [i]
                    (let [t (/ (double i) 30)
                          r (+ 0.3 (* 0.5 t))
                          a (* t 0.9)]
                      [(* r (Math/cos a)) (* r (Math/sin a))]))
                  (range 31))
      copies (make-rosette 6 motif)
      colors (cycle ["#e74c3c" "#3498db" "#2ecc71" "#f39c12" "#9b59b6" "#e67e22"
                     "#c0392b" "#2980b9" "#27ae60" "#d35400" "#8e44ad" "#1abc9c"])]
  (kind/plotly
   {:data (vec (map-indexed
                (fn [i copy]
                  {:type "scatter" :mode "lines"
                   :x (mapv first copy)
                   :y (mapv second copy)
                   :line {:color (nth colors i) :width 2}
                   :showlegend false})
                copies))
    :layout {:title "D₆ rosette — 12 copies of one curve"
             :xaxis {:visible false :scaleanchor "y"}
             :yaxis {:visible false}
             :width 400 :height 400}}))

Each copy is a group element’s action on the original curve. The \(D_6\) group has 12 elements — 6 rotations and 6 reflections — producing 12 copies. For more, see Symmetry Sketchpad.

Symmetry you can hear

The Klein four-group \(V_4\) gives four ways to transform a melody: original, retrograde (backwards), inversion (upside down), and retrograde inversion (both). Composers from Bach to Schoenberg have used these symmetries as compositional tools.

(def V4 (hm/product-group (hm/cyclic-group 2) (hm/cyclic-group 2)))

The subject of Bach’s Art of Fugue — a theme Bach designed to work under all four \(V_4\) transformations. Pairs of [pitch duration] (MIDI note numbers, duration in seconds):

(def motif [[62 0.8] [69 0.8] [65 0.8] [62 0.5] [61 0.5] [62 0.5] [64 0.5] [65 0.8]])
(defn apply-v4 [pivot [r i] melody]
  (let [m (if (= i 1) (mapv (fn [[p d]] [(- (* 2 pivot) p) d]) melody) melody)]
    (if (= r 1) (vec (reverse m)) m)))

The four forms:

(let [pivot (ffirst motif)]
  {:original (mapv first motif)
   :retrograde (mapv first (apply-v4 pivot [1 0] motif))
   :inversion (mapv first (apply-v4 pivot [0 1] motif))
   :retrograde-inv (mapv first (apply-v4 pivot [1 1] motif))})
{:original [62 69 65 62 61 62 64 65],
 :retrograde [65 64 62 61 62 65 69 62],
 :inversion [62 55 59 62 63 62 60 59],
 :retrograde-inv [59 60 62 63 62 59 55 62]}

Synthesize and play them:

(def sample-rate 44100.0)
(defn play [melody]
  (let [amp 2500.0
        offsets (reductions + 0 (map (fn [[_ d]] (long (* d sample-rate))) melody))
        total (last offsets)]
    (kind/audio
     {:sample-rate sample-rate
      :samples
      (dtype/make-reader
       :float32 total
       (let [[note-idx note-start]
             (loop [i 0]
               (if (< idx (nth offsets (inc i)))
                 [i (nth offsets i)]
                 (recur (inc i))))
             [pitch dur] (melody note-idx)
             n-note (long (* dur sample-rate))
             t (- idx note-start)
             attack (long (* 0.015 sample-rate))
             sounding (long (* 0.85 n-note))
             release (long (* 0.06 sample-rate))
             freq (* 440.0 (Math/pow 2.0 (/ (- (double pitch) 69.0) 12.0)))
             phase (/ (double t) sample-rate)]
         (if (>= t sounding)
           (float 0.0)
           (let [env (cond
                       (< t attack) (/ (double t) attack)
                       (> t (- sounding release))
                       (* (Math/exp (* -1.5 (/ (double (- t attack)) sounding)))
                          (/ (double (- sounding t)) release))
                       :else (Math/exp (* -1.5 (/ (double (- t attack)) sounding))))
                 wave (+ (* 0.65 (Math/sin (* 2.0 Math/PI freq phase)))
                         (* 0.25 (Math/sin (* 2.0 Math/PI 2.0 freq phase)))
                         (* 0.10 (Math/sin (* 2.0 Math/PI 3.0 freq phase))))]
             (float (* amp env wave))))))})))

Traverse the whole group — each element transforms the motif differently:

(play motif)
(play (apply-v4 (ffirst motif) [1 0] motif))
(play (apply-v4 (ffirst motif) [0 1] motif))
(play (apply-v4 (ffirst motif) [1 1] motif))

For twelve-tone rows, transposition, and more musical group theory, see Hearing Symmetry.

The library at a glance

harmonica provides:

Concept Groups
Cyclic groups Z/nZ Clock arithmetic, DFT
Dihedral groups Dₙ Polygon symmetries, necklaces
Symmetric groups Sₙ Permutations, card shuffling
Product groups G₁ × G₂ 2D DFT, Klein four-group
Tool Purpose
Character tables Fourier-analytic backbone
Fourier transform Decompose functions on groups
Group actions Orbits, Burnside counting, Pólya
Representation matrices Explicit matrix irreps via Young tableaux

Where to start

For a look at the internal data structures and algorithms, see Under the Hood.

source: notebooks/harmonica_book/quickstart.clj