Skip to content

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:

  1. Top generators. Compute top M = M/rad M and 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}.
  2. 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.
  3. 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 algebraD(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