Skip to content

Commit af9a5ba

Browse files
committed
feat: criticality worker init (wip)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 8a0c1d4 commit af9a5ba

7 files changed

Lines changed: 502 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Graph-derived signals for criticality scoring (ADR-0001 §Criticality scoring methodology).
2+
-- Populated by the criticality worker; NULL until first pass.
3+
4+
ALTER TABLE packages_universe
5+
ADD COLUMN IF NOT EXISTS transitive_dependent_count bigint,
6+
ADD COLUMN IF NOT EXISTS centrality_score numeric(10, 8);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
-- Replaces the placeholder rank_packages_universe() with the full ADR formula.
2+
--
3+
-- Formula per ADR-0001 Criticality scoring methodology:
4+
-- score = w_centrality * pct_rank( centrality_score ) within ecosystem
5+
-- + w_transitive * pct_rank( LN(1 + transitive_dependent_count) ) within ecosystem
6+
-- + w_dep_pkgs * pct_rank( LN(1 + dependent_packages_count) ) within ecosystem
7+
-- + w_dep_repos * pct_rank( LN(1 + dependent_repos_count) ) within ecosystem
8+
-- + w_downloads * pct_rank( LN(1 + downloads_30d) ) within ecosystem
9+
--
10+
-- All five weights must sum to 1.0 — caller's responsibility.
11+
-- centrality_score skips LN(): PageRank is already bounded in (0,1].
12+
-- COALESCE(x, 0) gives missing signals the floor percentile rather than an error.
13+
-- ROW_NUMBER() (not RANK()) keeps each ecosystem's critical set exactly at top-N.
14+
15+
CREATE OR REPLACE FUNCTION rank_packages_universe(
16+
weight_centrality numeric,
17+
weight_transitive numeric,
18+
weight_dependent_packages numeric,
19+
weight_dependent_repos numeric,
20+
weight_downloads numeric,
21+
critical_top_n_by_ecosystem jsonb
22+
)
23+
RETURNS TABLE(scored_rows int, ranked_rows int, propagated_rows int)
24+
LANGUAGE plpgsql AS $$
25+
DECLARE
26+
n_scored int;
27+
n_ranked int;
28+
n_propagated int;
29+
BEGIN
30+
-- ── Step 1: score ──────────────────────────────────────────────────────────
31+
WITH percentile_scores AS (
32+
SELECT
33+
id,
34+
(
35+
weight_centrality * PERCENT_RANK() OVER (
36+
PARTITION BY ecosystem ORDER BY COALESCE(centrality_score, 0))
37+
38+
+ weight_transitive * PERCENT_RANK() OVER (
39+
PARTITION BY ecosystem ORDER BY LN(1 + COALESCE(transitive_dependent_count, 0)))
40+
41+
+ weight_dependent_packages * PERCENT_RANK() OVER (
42+
PARTITION BY ecosystem ORDER BY LN(1 + COALESCE(dependent_packages_count, 0)))
43+
44+
+ weight_dependent_repos * PERCENT_RANK() OVER (
45+
PARTITION BY ecosystem ORDER BY LN(1 + COALESCE(dependent_repos_count, 0)))
46+
47+
+ weight_downloads * PERCENT_RANK() OVER (
48+
PARTITION BY ecosystem ORDER BY LN(1 + COALESCE(downloads_30d, 0)))
49+
)::numeric(10, 4) AS new_score
50+
FROM packages_universe
51+
)
52+
UPDATE packages_universe pu
53+
SET criticality_score = ps.new_score,
54+
last_rank_pass_at = NOW()
55+
FROM percentile_scores ps
56+
WHERE pu.id = ps.id
57+
AND pu.criticality_score IS DISTINCT FROM ps.new_score;
58+
59+
GET DIAGNOSTICS n_scored = ROW_COUNT;
60+
61+
-- ── Step 2: rank + flag ────────────────────────────────────────────────────
62+
WITH ranked AS (
63+
SELECT
64+
id, ecosystem,
65+
ROW_NUMBER() OVER (
66+
PARTITION BY ecosystem
67+
ORDER BY criticality_score DESC NULLS LAST, id
68+
) AS r
69+
FROM packages_universe
70+
WHERE purl IS NOT NULL
71+
),
72+
flagged AS (
73+
SELECT
74+
id, r,
75+
COALESCE(
76+
r <= (critical_top_n_by_ecosystem ->> ecosystem)::int,
77+
FALSE
78+
) AS new_is_critical
79+
FROM ranked
80+
)
81+
UPDATE packages_universe pu
82+
SET rank_in_ecosystem = f.r,
83+
is_critical = f.new_is_critical
84+
FROM flagged f
85+
WHERE pu.id = f.id
86+
AND (
87+
pu.rank_in_ecosystem IS DISTINCT FROM f.r
88+
OR pu.is_critical IS DISTINCT FROM f.new_is_critical
89+
);
90+
91+
GET DIAGNOSTICS n_ranked = ROW_COUNT;
92+
93+
-- ── Step 3: propagate to packages ─────────────────────────────────────────
94+
UPDATE packages p
95+
SET criticality_score = pu.criticality_score,
96+
is_critical = pu.is_critical,
97+
last_rank_pass_at = NOW()
98+
FROM packages_universe pu
99+
WHERE p.purl = pu.purl
100+
AND p.ecosystem = pu.ecosystem
101+
AND (
102+
p.criticality_score IS DISTINCT FROM pu.criticality_score
103+
OR p.is_critical IS DISTINCT FROM pu.is_critical
104+
);
105+
106+
GET DIAGNOSTICS n_propagated = ROW_COUNT;
107+
108+
RETURN QUERY SELECT n_scored, n_ranked, n_propagated;
109+
END;
110+
$$;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Context } from '@temporalio/activity'
2+
3+
import { getServiceChildLogger } from '@crowd/logging'
4+
5+
import { getPackagesDb } from '../db'
6+
import { buildGraph, computePageRank } from './graph'
7+
import { loadDirectEdges, mergeCentralityScores } from './queries'
8+
import { CentralityInput, CentralityResult } from './types'
9+
10+
const log = getServiceChildLogger('criticality')
11+
12+
function getConfig() {
13+
return {
14+
damping: Number(process.env.CRITICALITY_PAGERANK_DAMPING) || 0.85,
15+
maxIter: parseInt(process.env.CRITICALITY_PAGERANK_MAX_ITER ?? '', 10) || 100,
16+
convergence: Number(process.env.CRITICALITY_PAGERANK_CONVERGENCE) || 1e-6,
17+
}
18+
}
19+
20+
export async function criticalityComputePageRank(input: CentralityInput): Promise<CentralityResult> {
21+
const { ecosystem } = input
22+
const { damping, maxIter, convergence } = getConfig()
23+
const start = Date.now()
24+
const qx = await getPackagesDb()
25+
26+
// ── Step 1: build CSR graph
27+
const edges = await loadDirectEdges(qx, ecosystem)
28+
const edgeCount = edges.length
29+
const graph = buildGraph(edges)
30+
edges.length = 0 // release JS edge objects — CSR holds all graph data
31+
log.info({ ecosystem, nodeCount: graph.N, edgeCount }, 'graph loaded')
32+
33+
// ── Step 2 & 3: PageRank
34+
const { scores, iterations } = computePageRank(
35+
graph, damping, maxIter, convergence,
36+
(iter, delta) => {
37+
try { Context.current().heartbeat({ ecosystem, iter, delta }) } catch { /* standalone */ }
38+
},
39+
)
40+
log.info({ ecosystem, iterations, nodeCount: graph.N }, 'PageRank converged')
41+
42+
// ── Step 4: merge centrality_score into packages_universe
43+
const batch = Array.from(graph.nodeIndex.entries()).map(([packageId, idx]) => ({
44+
packageId,
45+
centralityScore: scores[idx],
46+
}))
47+
48+
const CHUNK = 10_000
49+
for (let i = 0; i < batch.length; i += CHUNK) {
50+
await mergeCentralityScores(qx, batch.slice(i, i + CHUNK))
51+
}
52+
53+
return { ecosystem, nodeCount: graph.N, edgeCount, iterations, durationMs: Date.now() - start }
54+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { DirectEdge } from './queries'
2+
3+
export interface Graph {
4+
nodeIndex: Map<number, number>
5+
nodeIds: number[]
6+
numDeps: Int32Array // how many packages each node depends on (divides its vote)
7+
rowPtr: Int32Array // rowPtr[v] = start of v's dependents in colData
8+
colData: Int32Array // flat array of dependent node indices
9+
N: number
10+
}
11+
12+
export function buildGraph(edges: DirectEdge[]): Graph {
13+
// Pass 0: assign contiguous indices
14+
const nodeIndex = new Map<number, number>()
15+
const nodeIds: number[] = []
16+
17+
for (const { packageId, dependsOnId } of edges) {
18+
if (!nodeIndex.has(packageId)) { nodeIndex.set(packageId, nodeIds.length); nodeIds.push(packageId) }
19+
if (!nodeIndex.has(dependsOnId)) { nodeIndex.set(dependsOnId, nodeIds.length); nodeIds.push(dependsOnId) }
20+
}
21+
22+
const N = nodeIds.length
23+
24+
// Pass 1: count
25+
const depCount = new Int32Array(N)
26+
const numDeps = new Int32Array(N)
27+
28+
for (const { packageId, dependsOnId } of edges) {
29+
depCount[nodeIndex.get(dependsOnId)!]++
30+
numDeps[nodeIndex.get(packageId)!]++
31+
}
32+
33+
// Build rowPtr (prefix sum of depCount)
34+
const rowPtr = new Int32Array(N + 1)
35+
for (let i = 0; i < N; i++) rowPtr[i + 1] = rowPtr[i] + depCount[i]
36+
37+
// Pass 2: fill colData
38+
const colData = new Int32Array(edges.length)
39+
const fillIdx = new Int32Array(N)
40+
41+
for (const { packageId, dependsOnId } of edges) {
42+
const v = nodeIndex.get(dependsOnId)!
43+
colData[rowPtr[v] + fillIdx[v]++] = nodeIndex.get(packageId)!
44+
}
45+
46+
return { nodeIndex, nodeIds, numDeps, rowPtr, colData, N }
47+
}
48+
49+
export function computePageRank(
50+
{ numDeps, rowPtr, colData, N }: Graph,
51+
damping = 0.85,
52+
maxIter = 100,
53+
convergence = 1e-6,
54+
onIteration?: (iter: number, delta: number) => void,
55+
): { scores: Float64Array; iterations: number } {
56+
const teleportation = (1 - damping) / N // base score every node gets regardless of links
57+
let scores = new Float64Array(N).fill(1 / N) // start: equal weight for all nodes
58+
let next = new Float64Array(N) // scratch buffer, swapped each iteration
59+
let iters = 0
60+
61+
for (iters = 1; iters <= maxIter; iters++) {
62+
63+
// Every node starts with the teleportation floor
64+
next.fill(teleportation)
65+
66+
// Each node v collects votes from packages that depend on it.
67+
// numDeps[dependent] is always >= 1 here — only packages with at least one
68+
// outgoing edge appear in colData, so division by zero cannot occur.
69+
// Dangling nodes (numDeps = 0) never appear in colData; their score
70+
// accumulates but never redistributes. This is acceptable because scores
71+
// are used for relative ranking via pct_rank(), not as absolute values.
72+
for (let v = 0; v < N; v++) {
73+
let incoming = 0
74+
for (let j = rowPtr[v]; j < rowPtr[v + 1]; j++) {
75+
const dependent = colData[j]
76+
incoming += scores[dependent] / numDeps[dependent]
77+
}
78+
next[v] = teleportation + damping * incoming
79+
}
80+
81+
// L1 delta: total change in scores across all nodes
82+
let delta = 0
83+
for (let i = 0; i < N; i++) delta += Math.abs(next[i] - scores[i])
84+
85+
;[scores, next] = [next, scores] // swap buffers — no data copy
86+
onIteration?.(iters, delta)
87+
88+
if (delta < convergence) break // scores have stabilised
89+
}
90+
91+
return { scores, iterations: iters }
92+
}
93+
94+
export function getDependents(graph: Graph, packageId: number): number[] {
95+
const idx = graph.nodeIndex.get(packageId)
96+
if (idx === undefined) return []
97+
const { rowPtr, colData, nodeIds } = graph
98+
const result: number[] = []
99+
for (let j = rowPtr[idx]; j < rowPtr[idx + 1]; j++) {
100+
result.push(nodeIds[colData[j]])
101+
}
102+
return result
103+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
2+
3+
export interface DirectEdge {
4+
packageId: number
5+
dependsOnId: number
6+
}
7+
8+
9+
// Unique direct package→package edges for the ecosystem.
10+
// DISTINCT deduplicates multi-version rows; parallel hint scoped to the transaction.
11+
export async function loadDirectEdges(qx: QueryExecutor, ecosystem: string): Promise<DirectEdge[]> {
12+
return qx.tx(async (tx) => {
13+
await tx.result(`SET LOCAL max_parallel_workers_per_gather = 4`)
14+
return tx.select(
15+
`SELECT DISTINCT pd.package_id AS "packageId",
16+
pd.depends_on_id AS "dependsOnId"
17+
FROM package_dependencies pd
18+
JOIN packages p
19+
ON p.id = pd.package_id
20+
AND p.ecosystem = $/ecosystem/
21+
WHERE pd.dependency_kind = 'direct'`,
22+
{ ecosystem },
23+
)
24+
})
25+
}
26+
27+
// Bulk-update centrality_score on packages_universe rows by joining through packages.
28+
// Uses unnest — one parameterised query regardless of row count, no string interpolation.
29+
// Isolated packages (not in the graph) remain NULL; rank_packages_universe() treats
30+
// NULL as 0 via COALESCE. Idempotent — safe for Temporal retries.
31+
export async function mergeCentralityScores(
32+
qx: QueryExecutor,
33+
rows: Array<{ packageId: number; centralityScore: number }>,
34+
): Promise<void> {
35+
if (rows.length === 0) return
36+
await qx.result(
37+
`UPDATE packages_universe pu
38+
SET centrality_score = v.score
39+
FROM unnest($(packageIds)::bigint[], $(scores)::numeric[]) AS v(package_id, score)
40+
JOIN packages p ON p.id = v.package_id
41+
WHERE pu.purl = p.purl`,
42+
{
43+
packageIds: rows.map(r => r.packageId),
44+
scores: rows.map(r => r.centralityScore),
45+
},
46+
)
47+
}

0 commit comments

Comments
 (0)