Research: PageRank Computation Bug in Gitea Robot API
Date: 2026-03-14 Phase: 1 -- Disciplined Research Status: Complete, awaiting approval for Phase 2
Problem Statement
The Gitea Robot API at https://git.terraphim.cloud returns uniform PageRank scores of 0.15 (which equals 1 - damping_factor where damping_factor = 0.85) for ALL issues, despite the tlaplus-ts repository having 8 issues and 12 dependency edges. All three endpoints exhibit this behaviour:
/api/v1/robot/ready-- returnspage_rank: 0.15for every issue/api/v1/robot/triage-- returnspagerank: 0.15for every recommendation/api/v1/robot/graph-- returnspage_rank: 0.15for every node
Expected behaviour: Issues that are depended upon by many downstream issues (e.g. root issues #1, #2) should have higher PageRank scores than leaf issues (e.g. #8) that have no dependents.
Success criteria:
- Root/hub issues receive higher PageRank scores than leaf issues
- PageRank scores vary across issues proportionally to dependency structure
- Sum of all PageRank scores approximates 1.0
- The algorithm correctly converges within configured iterations
Existing System Analysis
Code Locations
| File | Role |
|------|------|
| /Users/alex/projects/terraphim/gitea/models/issues/graph_cache.go | Core PageRank computation (lines 66-173), cache CRUD, ranked issue queries |
| /Users/alex/projects/terraphim/gitea/routers/api/v1/robot/ready_graph.go | Ready and Graph API handlers, reads cached PageRank |
| /Users/alex/projects/terraphim/gitea/routers/api/v1/robot/robot.go | Triage API handler, input validation, permission checks |
| /Users/alex/projects/terraphim/gitea/services/robot/robot.go | Triage service -- the only caller of CalculatePageRank() |
| /Users/alex/projects/terraphim/gitea/modules/setting/graph.go | Configuration (damping=0.85, iterations=100) |
| /Users/alex/projects/terraphim/gitea/models/issues/dependency.go | IssueDependency model -- defines issue_id and dependency_id columns |
| /Users/alex/projects/terraphim/gitea/models/migrations/v1_26/v326.go | Migration creating graph_cache table |
Architecture Flow
Request --> Router --> Service --> CalculatePageRank() --> graph_cache table --> Response- Triage endpoint (
robot.go:79) callsservice.Triage()which callsissues_model.CalculatePageRank(ctx, repoID, 0.85, 100)-- this is the only place that triggers computation. - Ready endpoint (
ready_graph.go:197) callsissues.GetPageRanksForRepo()-- this only reads from cache; it never triggers computation. - Graph endpoint (
ready_graph.go:471) callsissues.GetPageRanksForRepo()-- same as Ready, read-only.
Dependency Semantics in Gitea
From dependency.go (line 107-114):
When creating: CreateIssueDependency(ctx, user, issue, dep) stores IssueID = issue.ID, DependencyID = dep.ID. This means: "issue is blocked by dep" or equivalently "dep blocks issue".
Root Cause Analysis
Bug 1 (CRITICAL): Adjacency matrix direction is inverted
Location: graph_cache.go, lines 102-114
// adj[depID] = list of issues that depend on it (blocked by it)
for range deps The adjacency list adj[depID] maps a dependency (blocker) to the list of issues it blocks. This means adj encodes outgoing edges from blockers to blocked issues.
Then in the power iteration (lines 129-152):
for range validIssues This says: for each issue, find its blockers, and transfer rank FROM blockers TO the blocked issue. The outDegree is counted as the number of issues that the blocker blocks (adj[blockerID]).
The problem: In standard PageRank applied to dependency graphs, the "importance" should flow in the opposite direction. An issue that many other issues depend on (a root/hub issue) should accumulate more rank. The current code gives rank to blocked issues (leaves), which is the opposite of what is desired for task prioritisation.
However, this inversion alone would still produce non-uniform scores. The reason all scores are uniform is Bug 2.
Bug 2 (CRITICAL): Duplicate dependency edges cause incorrect graph construction
Location: graph_cache.go, lines 74-97
// Get all dependencies for this repo, joined with issue info to filter by repoID
db.
.
.
.
// Also get dependencies where the issue is the dependency (blocked by)
err = db.
.
.
.
// Merge both dependency lists
deps = The code runs two separate queries and merges them:
- Query 1: finds rows where
issue_idbelongs to this repo (and is open) - Query 2: finds rows where
dependency_idbelongs to this repo (and is open)
For a same-repo dependency (the common case where both the issue and its dependency are in the same repository), both queries will match the same row, producing duplicate edges in the deps slice.
Effect of duplicates on the adjacency list:
adj = With duplicate edges, each adj[depID] entry will contain the same issue ID twice, doubling the outDegree. Since PageRank distributes rank proportionally to 1/outDegree, the doubled out-degree halves each contribution.
Why this causes uniform scores: The duplicate entries also cause the power iteration inner loop to process each edge twice (once for each duplicate in deps), but the outDegree is also doubled. The net effect: 2 * (rank / (2 * outDegree)) = rank / outDegree, which is the same as without duplicates. So the duplicates cancel out in the iteration math.
This means the root cause for uniform scores must be elsewhere. Let me trace through the algorithm more carefully.
Bug 3 (CRITICAL -- the actual root cause): The DependencyWithRepo struct field tags do not match xorm expectations
Location: graph_cache.go, lines 59-64
The xorm tags here use table.column syntax (issue_dependency.issue_id). However, xorm does not support table-qualified column names in struct tags for Find() operations. The xorm tag is interpreted as the column name in the result set. When using Table("issue_dependency").Join(...), xorm builds a query like:
SELECT issue_dependency.issue_id, issue_dependency.dependency_id, issue.is_closed
FROM issue_dependency
INNER JOIN issue ON issue.id = issue_dependency.issue_id AND issue.repo_id = ?
WHERE issue.is_closed = ?But the result columns from this query are typically returned as just issue_id, dependency_id, and is_closed (without table prefixes). The xorm struct tags containing dots (issue_dependency.issue_id) will not match the result columns, and xorm will fail to populate the struct fields. The fields will remain at their zero values (0 for int64, false for bool).
This is the smoking gun. When all IssueID and DependencyID fields are 0:
validIssueswill contain only{0: true}-- a single phantom issueadjwill contain{0: [0, 0, ...]}-- self-loops on issue 0- The PageRank iteration will compute scores for issue ID 0 only
- All real issues will not appear in
pageRanks - When
UpdatePageRankis called, it will upsert a single row for issue_id=0
Then when the API handlers call GetPageRanksForRepo():
- No cached scores exist for any real issue IDs
- They fall back to the baseline:
1.0 - 0.85 = 0.15
This explains the uniform 0.15 scores perfectly.
Bug 4 (SECONDARY): Ready and Graph endpoints never trigger PageRank computation
Location: ready_graph.go, lines 197-201 and 471-475
Both the Ready and Graph endpoints call issues.GetPageRanksForRepo() which only reads from the cache. They never call CalculatePageRank() or EnsureRepoPageRankComputed().
If the Triage endpoint has never been called for a repository (or if the computation produced zero values due to Bug 3), the Ready and Graph endpoints will always return baseline values.
Bug 5 (MINOR): Testing plan tests are all marked TODO
The testing-plan.md lists 12 PageRank-specific tests, all marked as TODO. No unit tests exist for CalculatePageRank(). Had TestCalculatePageRank_SimpleChain been implemented, it would have caught the xorm struct tag issue immediately.
Verification of Root Cause Hypothesis
To confirm Bug 3, we can check whether xorm correctly populates DependencyWithRepo by:
- Adding debug logging in
CalculatePageRankto print the length ofdepsand the actual values ofdeps[0].IssueIDanddeps[0].DependencyID - Or by running the raw SQL query directly and comparing
The hypothesis predicts:
len(deps)will be > 0 (the JOIN works, rows are returned)deps[i].IssueIDanddeps[i].DependencyIDwill both be 0 for all entries- The log message on line 169 will report something like "1 issues, N dependencies" where 1 is the single phantom issue ID 0
Proposed Fixes
Fix 1: Correct the xorm struct tags (fixes Bug 3)
Replace the table-qualified tags with simple column names and add explicit column selection:
Or better, use Cols() in the query to select specific columns with aliases:
db.
.
Fix 2: Eliminate duplicate edges (fixes Bug 2)
Use a single query with OR condition, or use UNION to combine both directions, or query all dependencies and filter in Go:
db.
.
Fix 3: Reverse PageRank direction (fixes Bug 1)
For task prioritisation, rank should flow from blocked issues TO their blockers (upstream). An issue that blocks many others should accumulate more rank. Change the adjacency direction:
// adj[issueID] = list of dependencies (blockers) of this issue
// In PageRank terms: issue "links to" its dependencies
for range deps Then in the power iteration:
for range validIssues Or more efficiently, precompute incoming edges.
Fix 4: Make Ready and Graph trigger computation (fixes Bug 4)
Add EnsureRepoPageRankComputed() calls to both endpoints, or have them call CalculatePageRank() directly like the Triage endpoint does.
Fix 5: Add unit tests for PageRank calculation
Implement the tests from testing-plan.md, particularly:
TestCalculatePageRank_SimpleChainTestCalculatePageRank_StarPatternTestCalculatePageRank_SumToOne
Constraints and Risks
- Database compatibility: The fix must work with both SQLite and PostgreSQL backends. Raw SQL using
?placeholders is safe for xorm's multi-database support. - Cross-repo dependencies: The
issue_dependencytable can reference issues from different repositories. The fix must handle this correctly (only include issues from the target repo). - Performance: The current O(issues * deps) inner loop in the power iteration should be optimised to O(edges) per iteration. For small repos (< 100 issues) this is not critical, but for larger repos it will matter.
- Cache invalidation: There is no mechanism to invalidate PageRank cache when dependencies change. The
InvalidateCache()function exists but is never called from dependency CRUD operations. - Closed issue filtering: The current code filters closed issues from the dependency query but the
is_closedfield filtering may not work correctly if the xorm struct tags are broken (Bug 3).
Open Questions
-
Should PageRank direction match web PageRank or be inverted for task prioritisation? The testing plan and E2E scenario documentation suggest that root issues (blockers) should have the highest PageRank. The E2E doc says "Issue 1 should have highest PageRank" where Issue 1 is the root that blocks Issues 2 and 3. This confirms that rank should flow from blocked issues to their blockers.
-
Should closed issues be completely excluded or receive a baseline score? Currently the code tries to exclude them from the graph, which seems correct for prioritisation.
-
Should
EnsureRepoPageRankComputeduse a TTL-based check? ThePageRankCacheTTLsetting exists (default 300 seconds) but is never used in the actual cache check --hasPageRankCache()only checks whether rows exist, not their age. -
Is there a hook to invalidate cache when dependencies are added/removed? Currently there is not. Should
CreateIssueDependencyandRemoveIssueDependencycallInvalidateCache()?
Recommended Next Steps
- Confirm Bug 3 by adding temporary debug logging in
CalculatePageRank()and calling the Triage endpoint, or by running the xorm query in a test harness. - Fix Bug 3 (xorm struct tags) as the highest priority -- this is the root cause of the uniform scores.
- Fix Bug 2 (duplicate edges) to prevent subtle correctness issues.
- Fix Bug 1 (PageRank direction) to match the documented expectation that blockers rank higher.
- Fix Bug 4 (Ready/Graph not triggering computation) so all endpoints return computed scores.
- Write unit tests to prevent regression and validate mathematical correctness.
- Add cache invalidation on dependency CRUD operations.
Summary
The primary root cause is Bug 3: xorm struct tags with table-qualified names fail silently, causing all dependency data to be read as zero values. This produces a degenerate graph with a single phantom node (ID 0), whose computed PageRank is never stored for real issue IDs. Consequently, all API responses fall back to the baseline score of 1 - 0.85 = 0.15.
Secondary issues include duplicate edges from two overlapping queries (Bug 2), inverted PageRank flow direction (Bug 1), and endpoints that never trigger computation (Bug 4).