feat(graph): centrality + cluster analytics (corpus graph PR B)

The Obsidian "Graph Analysis" equivalent — surfaces influence and structure
beyond raw citation count.

Backend (new web/graph_metrics.py — pure, dependency-free, no DB → G2):
- PageRank (power-iteration), betweenness (Brandes), community (deterministic
  label-propagation + connected-components fallback), computed in-memory over
  the precedent citation subgraph that build_corpus_graph already fetched.
  Normalized 0–1; community ints dense-ranked by size (stable colours).
- GraphNode += pagerank/betweenness/community (None unless metrics=true).
- build_corpus_graph + /api/graph/corpus gain metrics=false (default path
  unchanged). Validated on the live corpus: 147 nodes in 13ms.

Frontend:
- graph.ts: GraphNode metrics fields + metrics param.
- graph-canvas: color-by (type | practice_area | precedent_level | community |
  recency) and size-by (in-degree | pagerank | betweenness) via colorForNode /
  radiusForNode; exported palettes.
- graph-view: colorBy/sizeBy controls; metrics requested only when needed;
  global metrics overlaid onto neighborhood nodes by id (a node's PageRank
  shouldn't change when focused); a ranking panel (Tabs: המשפיעות / גשרים,
  click → focus); dynamic legend per color-by.
- graph-filter-panel: "צביעה לפי" + "גודל נקודה לפי" Selects.

web-ui build + lint pass. Invariants: G2 (metrics pure, no DB writes),
UI2 (model grows on explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:04:47 +00:00
parent 106ab53231
commit 2fbc0cd3c2
7 changed files with 497 additions and 19 deletions

View File

@@ -35,6 +35,9 @@ export type GraphNode = {
court: string | null;
date: string | null; // ISO date
case_law_id: string | null;
pagerank: number | null; // normalized 01, only when metrics requested
betweenness: number | null; // normalized 01
community: number | null; // dense cluster id, 0 = largest
};
export type GraphFacets = {
@@ -72,6 +75,7 @@ export type GraphFilters = {
district?: string;
year_from?: number;
year_to?: number;
metrics?: boolean;
};
export const graphKeys = {
@@ -95,6 +99,7 @@ function buildParams(f: GraphFilters): string {
if (f.district) p.set("district", f.district);
if (f.year_from) p.set("year_from", String(f.year_from));
if (f.year_to) p.set("year_to", String(f.year_to));
if (f.metrics) p.set("metrics", "true");
return p.toString();
}