10 — Modules: representation and resolutions¶
The mathematics¶
For a bound quiver algebra A = kQ/I, a finite-dimensional right A-module M is a vector space carrying a right action of A. Every such module is glued from simples S_v (one per vertex), and each simple sits atop an indecomposable projective P_v = e_v A (the paths starting at v) and beneath an indecomposable injective I_v = D(A e_v) (the dual of the paths ending at v). The radical filtration M ⊇ rad M ⊇ rad² M ⊇ ... records the Loewy structure; the top M/rad M lists the generators and the socle the essential simple submodules. Homological questions — Hom_A(M, N), Ext^n_A(M, N), the projective dimension of M — are answered from a minimal projective resolution ... → Q_1 → Q_0 → M → 0.
How it is represented¶
A module is the class Module (modules/module.py). It stores its k-dimension dim
and a dict action mapping each algebra basis label (a string, e.g. "e_1",
"a", "a*b") to a dim × dim matrix (a list of lists of exact Domain elements)
of right multiplication. The convention is stated in every docstring: an element m is
a column vector in a fixed k-basis of M, and m·b = action[b] @ m; because paths
compose left-to-right, the action is an anti-homomorphism,
action["x*y"] = action["y"] @ action["x"]. The vertex subspace M·e_v is the image of
action["e_v"], so the dimension vector — a dict vertex → int — is just
rank(action["e_v"]) per vertex, and these ranks sum to dim. A simple S_v has dim 1,
action["e_v"] = [[1]] and every other label acting as [[0]]. A projective P_v is read
directly off the algebra's own multiplication table: its basis is the sub-list of
A.basis_labels whose path starts at v, and action[b] is right multiplication by b
restricted to that sub-basis (column k = coordinates of pathₖ·b). An injective I_v is the
transpose of the left-multiplication action on the paths ending at v (the k-dual).
All matrix work goes through modules/linalg_mod.py, which is a thin exact layer over
fields.linalg (rank, nullspace, rref, solve): matrix product, Kronecker
product, kernel, column-space pivots, "express these columns in that basis", and the
greedy "independent-modulo-a-subspace" selector that picks minimal generators. Nothing
here is field-specific, so a module lives over ℚ, ℚ(α), GF(p), or GF(p^n) identically —
the same code, different Domain.
How the computation runs¶
Radical, top, socle¶
rad M (modules/radtopsoc.py) is the sum of the images of the arrow-action matrices
(M·rad A = Σ_α image(action[α])), reduced to an independent column basis; top M is
the quotient M/rad M; soc M is the intersection of the arrow-action kernels
(∩_α ker(action[α])). A submodule (radical, socle) is rebuilt as its own Module by
restricting the action — for each generator b, apply action[b] to each submodule
basis column and solve for the coordinates back in that basis (solve_columns); a
quotient (top) is rebuilt by choosing coset representatives independent modulo the
submodule and reading the action modulo it. Iterating rad gives the radical series, and
its length is the Loewy length.
Hom and Ext¶
Hom_A(M, N) (modules/hom.py) is the space of matrices φ (dim N × dim M) commuting
with the action: N.action[b] @ φ = φ @ M.action[b] for every generator b (arrows and
idempotents suffice). Column-stacking turns this into one homogeneous linear system
(I ⊗ N.action[b] − M.action[b]ᵀ ⊗ I) vec(φ) = 0; the nullspace is Hom, its dimension is
A.hom(M, N). Ext^n (modules/ext.py) applies Hom_A(−, N) to a minimal resolution of
M and takes cohomology: with the Hom-space bases H_n, the coboundary δ^n is precomposition
with d_{n+1}, and dim Ext^n = dim H_n − rank δ^n − rank δ^{n-1}.
The minimal projective resolution¶
modules/resolution.py (generalized from the bridge obstruction/module_ext.py, which
was hardcoded to a 4-vertex diamond over ℚ; here MIT-headered, over any vertex set and any
Domain) builds ... → Q_1 → Q_0 → M → 0 by iterated projective covers
(Green–Solberg–Zacharia). One step:
- Top generators. Compute
top M = M/rad Mand choose a k-basis, each vector lifted into a single vertex block M·e_v (homogeneous generators). If vertex v supplies t_v generators, the cover is Q_0 = ⊕_v P_v^{t_v}. - Cover map. d_0 : Q_0 → M sends the canonical generator of each P_v summand (its
e_v basis vector) to the lifted top-generator g; a general P_v basis vector for path p
maps to g·p =
action_M[p] @ g. - Syzygy. Ω_1 = ker(d_0), a submodule of Q_0, rebuilt by restriction; cover it to get Q_1 and d_1 = (cover of Ω_1) followed by (inclusion Ω_1 ↪ Q_0). Repeat.
Minimality is guaranteed because generators are chosen independent modulo the radical
(the independent_modulo selector), so d_n(Q_n) ⊆ rad Q_{n-1} and the summand counts are
the true Betti numbers. The resolution terminates at length n exactly when Ω_n = 0
(projective dimension n); a term dim blow-up past max_term_dim raises DepthLimitError
with the certified length. M.projective_resolution(k) returns a ProjectiveResolution:
.term(n) (the summand vertices), .betti(n), .differential(n), .pd(), and a readable
P_1 <- P_2 <- 0 repr. global_dimension(A) = sup_v pd(S_v) — exact when every simple
resolves within the depth budget, else a labeled certified lower bound.
The opposite algebra, duality D, and the AR translates τ / τ⁻ (Plan 23)¶
The Auslander–Reiten translate is built from two functors that share one engine.
A^op (modules/opposite.py::opposite_algebra) is the same k-space with the
reversed product x·^op y := y·x. It is realized as a first-class Algebra:
the quiver reverses every arrow (a: s→t ↦ a: t→s), the structure constants
transpose (T^op[i][j] = T[j][i]), the basis labels reverse (e_v ↦ e_v,
a*b*c ↦ c*b*a), and the relations reverse word-wise. Reversing an arrow flips
its source/target and the multiplication order, and the two flips are
consistent because reverse(q·p) = reverse(p)*reverse(q) in Q^op. The index
set is preserved (index i in A ↔ index i in A^op, same vector, reversed label),
which is what makes the duality and the transpose coordinate-clean and lets us
skip re-running Gröbner completion. A.opposite() is a cached involution
(A.opposite().opposite() is A).
D = Hom_k(−, k) (modules/duality.py::dualize) sends a right A-module M to a
right A^op-module DM by transposing every action matrix and reversing every
label: action_{DM}[reverse(ℓ)] = action_M[ℓ]ᵀ. This is exactly the anti-homo
consistency R_{y·x}ᵀ = (R_x R_y)ᵀ. D preserves dimension vectors but
transposes arrow actions; it is contravariant and D∘D = id. The injective
builder I_v = D(A e_v) is the only prior (implicit) use of D — D(P_v^{op})
reproduces injective(A, v) exactly (a tested agreement).
Tr (transpose_module) reads the minimal presentation P₁ →d₁ P₀ → M → 0,
extracts the corner elements y_{ij} ∈ e_{v_i}A e_{w_j} off d₁'s canonical-
generator columns, and forms Tr M = coker(d₁*) as a right A^op-module, where
d₁* is the corner-transpose: each source generator g_i ↦ Σ_j ȳ_{ij} (left-mult
by ȳ in A^op), assembled through the target A^op-projectives just like a cover
map, then a quotient. Minimality of the presentation is load-bearing (a
non-minimal one gives Tr ⊕ projectives).
τ M = D(Tr M) τ⁻ M = Tr(D M) (`M.tau()`, `M.tau_minus()`)
Both land back over A (since (A^op)^op = A). τ(projective) = 0 and
τ⁻(injective) = 0 fall out of the construction (the projective's presentation has
P₁ = 0; D(injective) is projective over A^op). For M indecomposable
non-projective, τ⁻τM ≅ M — verified by is_isomorphic (modules/hom.py), which
certifies an isomorphism by an exact invertible-hom witness: it searches
combinations of a Hom-space basis (exhaustively over a small GF(p); by an exact
generic-rank/function-field computation over char-0, sound by Noether–Deuring),
returns a positive answer only with a witness, and refuses loudly rather than
guess when a large GF(pⁿ) exceeds its budget.
Injective resolutions and injective dimension (Plan 23)¶
The dual of the projective machinery, on the same op+D engine
(modules/injective.py): the injective envelope E(M) = D(projective cover of DM
over A^op), and more generally E^n = D(P_n) where P_• is the minimal projective
resolution of DM over A^op. Because D preserves dimension vectors, the injective
coresolution 0 → M → E⁰ → E¹ → … has term dimension vectors equal to those of the
projective resolution of DM, and inj.dim_A(M) = pd_{A^op}(DM).
M.injective_resolution(k) returns an InjectiveResolution (mirroring
ProjectiveResolution); M.injective_dimension(bound) is an int, or None when
unresolved within the budget (infinite — e.g. a self-injective algebra's non-
projective modules).
Cross-checks (Plan 23)¶
Theory oracles pin values without the [qpa] extra: the hereditary Coxeter
transformation dim τM = Φ⁻ᵀ·dim M (Φ = A.coxeter_matrix(), the e_iAe_j Cartan
convention), the explicit kA₂/kA₃ AR tables, self-injective ⇒ inj.dim ∈ {0, ∞},
Nakayama τ-orbits, and max_v inj.dim S_v = gl.dim. QPA/GAP is the independent
oracle (qpa/crosscheck.py, -m qpa): our modules are emitted into QPA's
representation form (modules/qpa_module.py::graded_form, transposing to QPA's
row convention) and compared against DTr/TrD (dimension vectors +
IsomorphicModules), ProjectiveResolution, DualOfModule, and
InjDimensionOfModule across the zoo including the Plan-18 multi-vertex
line_abc_cde.
Left modules alongside right (Plan 24)¶
The surface accepts a side: A.simple(v, side=…), A.projective(v, side=…),
A.injective(v, side=…), and the general A.module(dimvec, arrow_matrices,
side=…) all take side="right" (the default) or side="left". Right is
byte-unchanged — the flag defaults to right and no right-module output moves.
The trick is that a left A-module IS a right A^op-module, and Plan 23 already
made A^op a first-class Algebra. So a Module keeps storing its action as a
right action of a representation algebra self.algebra, and a new attribute
self.side records how to read it: a left A-module carries self.algebra = A^op
and self.side = "left". The derived property self.base_algebra is self.algebra
when right and self.algebra.opposite() when left — the algebra the user reads the
module over (for a right module it is self.algebra, so the repr is identical
to before; a left module prints left <A>). Left construction routes through
opposite_algebra(A) and re-tags with M.with_side("left") (a relabelling of the
same representation, no recomputation).
No mathematics is forked. Every algorithm — radical/top/soc, Hom/End/Ext,
is_isomorphic, projective covers & resolutions, injective resolutions & injective
dimension, τ/τ⁻ — reads only (self.algebra, self.action) and never consults the
side; a left A-module runs them over A^op, which by definition computes the
left-A-module invariant. The side is threaded only so derived modules of a
left module are themselves presented on the left (an honest repr, never a right
A^op surprise): submodule/quotient inherit the ambient module's side,
_direct_sum/projective_cover tag their output with the covered module's side,
and resolution terms inherit through the syzygy chain — label-only edits, identical
numbers.
The duality D is side-aware: at the representation level it is unchanged
(transpose every action matrix, reverse every label, R → R^op), but the
categorical side flips, so D exchanges the two sides over the same base
algebra — D(right A-mod) = left A-mod and vice versa (its classical
contravariant form; D∘D = id, dimension vectors preserved). Tr flips the side
the same way (Hom_A(−,A) lands in the other-side modules). Because τ = D∘Tr and
τ⁻ = Tr∘D each flip twice, τ/τ⁻ preserve the side. One consequence to
note (Plan 24 changed it deliberately): M.dualize() of a right A-module is now a
left A-module (previously surfaced as a right A^op-module) — the stored
representation and every action byte are unchanged, only the tag and repr move, so
injective.py, τ, τ⁻ are all numerically identical. I_v = D(A e_v) is now
spelled honestly as A.projective(v, side="left").dualize().
Comparing across the boundary is a category error: is_isomorphic, A.hom,
A.ext refuse loudly (QuiverlabError) when the two arguments are not the same
side over the same base algebra — the guard runs before the dim/dim-vector
fast-paths, so a left S_v and a right S_v sharing a dimension vector still
refuse rather than falsely report iso. k[x]/(x^n) is self-opposite, so its left
and right modules coincide; on kA₂ the asymmetry is sharp — right
P(1) = e_1A has dimvec {1:1, 2:1} while left P(1) = Ae_1 has dimvec
{1:1, 2:0} (the honest witness that the sides are not conflated). QPA (right-module
native) validates the left side by being fed the opposite algebra
(A.opposite()): the left module's underlying right-A^op representation is exactly
QPA's input.
A worked micro-example — S_1 over the A₂ path algebra¶
linear_path_algebra(2) is Q: 1 → 2 with basis ["e_1", "e_2", "a1"] (the arrow is
auto-named a1). The simple S_1 has action["e_1"] = [[1]],
action["e_2"] = action["a1"] = [[0]]. Its cover: top S_1 = S_1, one generator at vertex
1, so Q_0 = P_1 = e_1 A (basis [e_1, a1], dimvec {1:1, 2:1}) and d_0 : P_1 → S_1 kills a1.
The syzygy Ω_1 = ker(d_0) = span{a1} ≅ S_2 = P_2 (basis [e_2],
dimvec {1:0, 2:1}), which is projective, so Q_1 = P_2 and Ω_2 = 0. The resolution is
0 → P_2 → P_1 → S_1 → 0, pd(S_1) = 1, and since pd(S_2) = 0 the algebra is hereditary,
global_dimension = 1. Then Hom(S_1, S_2) = 0 and Ext^1(S_1, S_2) = 1 (the single arrow
1 → 2). (These values were produced by running the code.)
Where to look in the code¶
| concept | file | function / class |
|---|---|---|
| the module object, dimension vector | modules/module.py |
Module, dimension_vector, from_arrow_action |
| exact matrix layer over a Domain | modules/linalg_mod.py |
matmul, kron, kernel_columns, independent_modulo, solve_columns |
| simples / projectives / injectives | modules/builders.py |
simple, projective, injective |
| radical / top / socle, submodule/quotient | modules/radtopsoc.py |
radical, top, socle, submodule, quotient |
| Hom / End, module isomorphism | modules/hom.py |
hom_space, hom_dim, end_dim, is_isomorphic |
| minimal projective resolution | modules/resolution.py |
minimal_resolution, projective_cover, ProjectiveResolution |
| module Ext^n, global dimension | modules/ext.py |
ext, ext_dims, global_dimension |
| opposite algebra A^op | modules/opposite.py |
opposite_algebra, reverse_label |
| duality D, transpose Tr, τ / τ⁻ | modules/duality.py |
dualize, transpose_module, tau, tau_minus |
| injective resolutions, injective dimension | modules/injective.py |
injective_resolution, injective_dimension, InjectiveResolution |
| module → QPA representation form | modules/qpa_module.py |
graded_form |
| QPA module oracles (τ/τ⁻, resolutions, inj.dim) | qpa/crosscheck.py, qpa/scripts.py |
crosscheck_tau, crosscheck_proj_resolution, crosscheck_inj_resolution, crosscheck_inj_dimension, module_decl |
| left/right side (Plan 24) | modules/module.py, core/algebra.py |
Module.side, Module.base_algebra, Module.with_side, _other_side, side= on simple/projective/injective/module |
| category guard (side/algebra) | modules/hom.py |
_assert_comparable (reused by is_isomorphic, hom_dim, ext) |
| public methods on the algebra | core/algebra.py |
simple, projective, injective, module, opposite, hom, ext, global_dimension |
| public methods on the module | modules/module.py |
dualize, transpose, tau, tau_minus, is_isomorphic, injective_resolution, injective_dimension, with_side |