concierge-graph 3.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
storage/logic.py ADDED
@@ -0,0 +1,703 @@
1
+ """
2
+ storage/logic.py - Grafo Concierge v3.8.0 (Absolute Solidity)
3
+
4
+ Graph Intelligence Core — Apex Algorithms.
5
+
6
+ Three pillars:
7
+ 1. Trajectories Decay (Version-Binding)
8
+ 2. Node Centrality (normalized in-degree + Super-Node detection)
9
+ 3. Weighted Hybrid Search (FTS5 BM25 + Vector + Max(Recency, Centrality))
10
+
11
+ Reference formulas (Architecture v3.8):
12
+ - Centrality: min(in_degree / 10, 1.0)
13
+ - Recency: max(e^(-λ × t), 0.01) where λ = ln(2)/7 ≈ 0.0990
14
+ - Final Score: 0.50×vector + 0.25×fts5 + 0.25×max(recency, centrality)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import math
21
+ from datetime import datetime, timezone
22
+ from typing import Any, Optional, TYPE_CHECKING
23
+
24
+ from storage.schema import VALID_STATUSES
25
+
26
+ if TYPE_CHECKING:
27
+ from core.config import ConciergeConfig
28
+
29
+ logger = logging.getLogger("grafo-concierge.logic")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Specific exceptions for the intelligence module
34
+ # ---------------------------------------------------------------------------
35
+
36
+ class TrajectoryNotFoundError(Exception):
37
+ """Trajectory with the provided ID does not exist in the database."""
38
+
39
+
40
+ class InvalidTransitionError(Exception):
41
+ """Illegal status transition (e.g. ARCHIVED → ACTIVE)."""
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # GraphLogic — Grafo Concierge intelligence engine
46
+ # ---------------------------------------------------------------------------
47
+
48
+ class GraphLogic:
49
+ """Intelligence engine over the graph persisted in SQLite.
50
+
51
+ Depends on a ConnectionManager (connection.py) for database access.
52
+ Reads via conn_manager.read(), writes via conn_manager.write().
53
+
54
+ Args:
55
+ conn_manager: Instance of ConnectionManager.
56
+ """
57
+
58
+ # --- Recency Constants (Exponential Decay) ---
59
+ # Half-life of 7 days: after 7 days without commit, score drops to 0.50.
60
+ RECENCY_HALF_LIFE_DAYS: float = 7.0
61
+ RECENCY_LAMBDA: float = math.log(2) / 7.0 # ≈ 0.09902
62
+ RECENCY_MIN_SCORE: float = 0.01 # Old nodes never reach zero
63
+
64
+ # --- Centrality Constants ---
65
+ # A node with 10+ dependents is considered a "Super-Node" (score = 1.0).
66
+ CENTRALITY_MAX_IN_DEGREE: int = 10
67
+
68
+ # --- Hybrid Search Weights v4 ---
69
+ WEIGHT_VECTOR: float = 0.50
70
+ WEIGHT_FTS5: float = 0.25
71
+ WEIGHT_RECENCY_CENTRALITY: float = 0.25
72
+
73
+ # --- Valid status transitions for Trajectories ---
74
+ # Each key maps to the states it can transition to.
75
+ _VALID_TRANSITIONS: dict[str, frozenset[str]] = {
76
+ "ACTIVE": frozenset({"STALE", "ARCHIVED"}),
77
+ "STALE": frozenset({"ACTIVE", "ARCHIVED"}),
78
+ "ARCHIVED": frozenset(), # Terminal state — no return
79
+ }
80
+
81
+ def __init__(self, conn_manager: Any, config: Optional["ConciergeConfig"] = None) -> None:
82
+ self._conn = conn_manager
83
+
84
+ # If config provided, overrides class constants with user values
85
+ if config is not None:
86
+ self.WEIGHT_VECTOR = config.weight_vector
87
+ self.WEIGHT_FTS5 = config.weight_fts5
88
+ self.WEIGHT_RECENCY_CENTRALITY = config.weight_recency_centrality
89
+ self.CENTRALITY_MAX_IN_DEGREE = config.centrality_max_in_degree
90
+ self.RECENCY_HALF_LIFE_DAYS = config.recency_half_life_days
91
+ self.RECENCY_LAMBDA = config.recency_lambda
92
+ self.RECENCY_MIN_SCORE = config.recency_min_score
93
+
94
+ # ===================================================================
95
+ # 1. TRAJECTORIES DECAY (Version-Binding)
96
+ # ===================================================================
97
+
98
+ def decay_trajectory(self, trajectory_id: int, new_status: str) -> bool:
99
+ """Changes status of an episodic trajectory with transition validation.
100
+
101
+ Transition rules (state machine):
102
+ ACTIVE → STALE, ARCHIVED
103
+ STALE → ACTIVE, ARCHIVED (re-activation allowed)
104
+ ARCHIVED → (terminal, no transition allowed)
105
+
106
+ Args:
107
+ trajectory_id: ID in trajectories table.
108
+ new_status: Target status (ACTIVE, STALE or ARCHIVED).
109
+
110
+ Returns:
111
+ True if the transition was applied.
112
+
113
+ Raises:
114
+ ValueError: If new_status is not a valid status.
115
+ TrajectoryNotFoundError: If trajectory_id does not exist.
116
+ InvalidTransitionError: If the transition is illegal.
117
+ """
118
+ # Validation 1: is destination status recognized?
119
+ if new_status not in VALID_STATUSES:
120
+ raise ValueError(
121
+ f"Invalid status: '{new_status}'. "
122
+ f"Accepted values: {sorted(VALID_STATUSES)}"
123
+ )
124
+
125
+ # Reading current status
126
+ with self._conn.read() as conn:
127
+ row = conn.execute(
128
+ "SELECT id, status FROM trajectories WHERE id = ?",
129
+ (trajectory_id,)
130
+ ).fetchone()
131
+
132
+ if row is None:
133
+ raise TrajectoryNotFoundError(
134
+ f"Trajectory ID={trajectory_id} not found."
135
+ )
136
+
137
+ current_status = row["status"]
138
+
139
+ # Validation 2: is transition legal?
140
+ allowed = self._VALID_TRANSITIONS.get(current_status, frozenset())
141
+ if new_status not in allowed:
142
+ raise InvalidTransitionError(
143
+ f"Illegal transition: {current_status} → {new_status}. "
144
+ f"Allowed transitions from '{current_status}': {sorted(allowed) or 'none (terminal)'}"
145
+ )
146
+
147
+ # Serialized write
148
+ def _do(conn: Any, tid: int, status: str) -> bool:
149
+ conn.execute(
150
+ "UPDATE trajectories SET status = ? WHERE id = ?",
151
+ (status, tid)
152
+ )
153
+ return True
154
+
155
+ result = self._conn.write(_do, trajectory_id, new_status)
156
+ logger.info(
157
+ "Trajectory ID=%d: %s → %s", trajectory_id, current_status, new_status
158
+ )
159
+ return result
160
+
161
+ def bulk_decay_stale_trajectories(
162
+ self, project_uuid: str, stale_threshold_days: int = 30
163
+ ) -> int:
164
+ """Marks as STALE all ACTIVE trajectories older than the threshold.
165
+
166
+ Used by Background Janitor in the Reconciliation Loop.
167
+
168
+ Args:
169
+ project_uuid: Target project UUID.
170
+ stale_threshold_days: Days since created_at to consider stale.
171
+
172
+ Returns:
173
+ Number of affected trajectories.
174
+ """
175
+ def _do(conn: Any, pu: str, days: int) -> int:
176
+ cursor = conn.execute(
177
+ """UPDATE trajectories
178
+ SET status = 'STALE'
179
+ WHERE project_uuid = ?
180
+ AND status = 'ACTIVE'
181
+ AND julianday('now') - julianday(created_at) > ?""",
182
+ (pu, days)
183
+ )
184
+ return cursor.rowcount
185
+
186
+ affected = self._conn.write(_do, project_uuid, stale_threshold_days)
187
+ if affected > 0:
188
+ logger.info(
189
+ "Bulk decay: %d trajectories marked STALE in project %s (threshold=%d days)",
190
+ affected, project_uuid, stale_threshold_days
191
+ )
192
+ return affected
193
+
194
+ # ===================================================================
195
+ # 2. CENTRALITY (normalized in-degree + Super-Nodes)
196
+ # ===================================================================
197
+
198
+ def compute_centrality(self, node_id: int) -> float:
199
+ """Calculates normalized centrality of a node.
200
+
201
+ Formula: min(in_degree / CENTRALITY_MAX_IN_DEGREE, 1.0)
202
+
203
+ A node with in_degree >= 10 is a "Super-Node": stable core code
204
+ that many files depend on. Receives maximum centrality (1.0),
205
+ protecting it against low recency penalty in Hybrid Search.
206
+
207
+ Args:
208
+ node_id: Node ID in nodes table.
209
+
210
+ Returns:
211
+ Float in interval [0.0, 1.0].
212
+ """
213
+ with self._conn.read() as conn:
214
+ row = conn.execute(
215
+ "SELECT COUNT(*) AS in_deg FROM edges WHERE target_id = ?",
216
+ (node_id,)
217
+ ).fetchone()
218
+
219
+ in_degree = row["in_deg"] if row else 0
220
+ centrality = min(in_degree / self.CENTRALITY_MAX_IN_DEGREE, 1.0)
221
+
222
+ logger.debug(
223
+ "Centrality node ID=%d: in_degree=%d, score=%.4f%s",
224
+ node_id, in_degree, centrality,
225
+ " [SUPER-NODE]" if centrality >= 1.0 else ""
226
+ )
227
+ return centrality
228
+
229
+ def compute_centrality_batch(self, node_ids: list[int]) -> dict[int, float]:
230
+ """Calculates centrality for multiple nodes in a single SQL query.
231
+
232
+ Args:
233
+ node_ids: List of node IDs.
234
+
235
+ Returns:
236
+ Dict mapping node_id → normalized centrality [0.0, 1.0].
237
+ Nodes without incoming edges return 0.0.
238
+ """
239
+ if not node_ids:
240
+ return {}
241
+
242
+ # Initializes all with 0.0 (in case they have no edges)
243
+ result: dict[int, float] = {nid: 0.0 for nid in node_ids}
244
+
245
+ placeholders = ",".join("?" for _ in node_ids)
246
+ with self._conn.read() as conn:
247
+ rows = conn.execute(
248
+ f"""SELECT target_id, COUNT(*) AS in_deg
249
+ FROM edges
250
+ WHERE target_id IN ({placeholders})
251
+ GROUP BY target_id""",
252
+ tuple(node_ids)
253
+ ).fetchall()
254
+
255
+ for row in rows:
256
+ in_deg = row["in_deg"]
257
+ result[row["target_id"]] = min(in_deg / self.CENTRALITY_MAX_IN_DEGREE, 1.0)
258
+
259
+ super_nodes = [nid for nid, score in result.items() if score >= 1.0]
260
+ if super_nodes:
261
+ logger.debug("Super-Nodes detected (batch): %s", super_nodes)
262
+
263
+ return result
264
+
265
+ # ===================================================================
266
+ # 3. RECENCY SCORE (Exponential Decay)
267
+ # ===================================================================
268
+
269
+ def compute_recency_score(self, node_id: int) -> float:
270
+ """Calculates recency score via exponential decay.
271
+
272
+ Formula: max(e^(-λ × t), RECENCY_MIN_SCORE)
273
+ Where:
274
+ λ = ln(2) / 7 ≈ 0.09902
275
+ t = days since nodes.last_commit_at
276
+
277
+ If last_commit_at is NULL, returns RECENCY_MIN_SCORE (0.01).
278
+
279
+ Args:
280
+ node_id: Node ID.
281
+
282
+ Returns:
283
+ Float in interval [0.01, 1.0].
284
+ """
285
+ with self._conn.read() as conn:
286
+ row = conn.execute(
287
+ "SELECT last_commit_at FROM nodes WHERE id = ?",
288
+ (node_id,)
289
+ ).fetchone()
290
+
291
+ if row is None or row["last_commit_at"] is None:
292
+ return self.RECENCY_MIN_SCORE
293
+
294
+ return self._calculate_decay(row["last_commit_at"])
295
+
296
+ def compute_recency_batch(self, node_ids: list[int]) -> dict[int, float]:
297
+ """Calculates recency in batch for multiple nodes.
298
+
299
+ Args:
300
+ node_ids: List of node IDs.
301
+
302
+ Returns:
303
+ Dict mapping node_id → recency score [0.01, 1.0].
304
+ """
305
+ if not node_ids:
306
+ return {}
307
+
308
+ result: dict[int, float] = {}
309
+ placeholders = ",".join("?" for _ in node_ids)
310
+
311
+ with self._conn.read() as conn:
312
+ rows = conn.execute(
313
+ f"SELECT id, last_commit_at FROM nodes WHERE id IN ({placeholders})",
314
+ tuple(node_ids)
315
+ ).fetchall()
316
+
317
+ for row in rows:
318
+ if row["last_commit_at"] is None:
319
+ result[row["id"]] = self.RECENCY_MIN_SCORE
320
+ else:
321
+ result[row["id"]] = self._calculate_decay(row["last_commit_at"])
322
+
323
+ # Ensures not found nodes return minimum score
324
+ for nid in node_ids:
325
+ if nid not in result:
326
+ result[nid] = self.RECENCY_MIN_SCORE
327
+
328
+ return result
329
+
330
+ def _calculate_decay(self, last_commit_at: str) -> float:
331
+ """Applies exponential decay formula.
332
+
333
+ Args:
334
+ last_commit_at: ISO timestamp of last commit (e.g. '2026-05-01 12:00:00').
335
+
336
+ Returns:
337
+ Score in interval [RECENCY_MIN_SCORE, 1.0].
338
+ """
339
+ try:
340
+ commit_dt = datetime.fromisoformat(last_commit_at)
341
+ now = datetime.utcnow()
342
+ delta_days = max((now - commit_dt).total_seconds() / 86400, 0.0)
343
+ except (ValueError, TypeError):
344
+ logger.warning("invalid last_commit_at: '%s'. Using minimum score.", last_commit_at)
345
+ return self.RECENCY_MIN_SCORE
346
+
347
+ # e^(-λ × t) with floor at MIN_SCORE
348
+ score = math.exp(-self.RECENCY_LAMBDA * delta_days)
349
+ return max(score, self.RECENCY_MIN_SCORE)
350
+
351
+ # ===================================================================
352
+ # 4. FTS5 — Text search with normalized BM25
353
+ # ===================================================================
354
+
355
+ def fts_search(
356
+ self,
357
+ query: str,
358
+ project_uuid: Optional[str] = None,
359
+ node_type: Optional[str] = None,
360
+ limit: int = 20,
361
+ ) -> list[dict]:
362
+ """Text search via FTS5 with BM25 normalized to [0, 1].
363
+
364
+ SQLite returns bm25() as negative value (more negative = more relevant).
365
+ We normalize to [0, 1] using: 1.0 - (rank / min_rank)
366
+ where min_rank is the most negative (most relevant) value of the batch.
367
+
368
+ Args:
369
+ query: Search text. Special characters are escaped.
370
+ project_uuid: Optional filter by project (Strict Scoping).
371
+ node_type: Targeted filter (FACT, SKILL, INSIGHT, TRAJECTORY, PATCH).
372
+ limit: Maximum results.
373
+
374
+ Returns:
375
+ List of dicts with node fields + normalized 'bm25_score' [0, 1].
376
+ Sorted from most relevant to least relevant.
377
+ """
378
+ # Escapes quotes in query to prevent FTS5 injection
379
+ safe_query = query.replace('"', '""')
380
+
381
+ # Assembles the SQL query dynamically according to the filters
382
+ sql = """
383
+ SELECT n.*, bm25(nodes_fts) AS rank
384
+ FROM nodes_fts f
385
+ JOIN nodes n ON n.id = f.rowid
386
+ WHERE nodes_fts MATCH ?
387
+ """
388
+ params: list[Any] = [f'"{safe_query}"']
389
+
390
+ if project_uuid:
391
+ sql += " AND n.project_uuid = ?"
392
+ params.append(project_uuid)
393
+
394
+ if node_type:
395
+ sql += " AND n.node_type = ?"
396
+ params.append(node_type)
397
+
398
+ sql += " ORDER BY rank LIMIT ?"
399
+ params.append(limit)
400
+
401
+ with self._conn.read() as conn:
402
+ rows = conn.execute(sql, tuple(params)).fetchall()
403
+
404
+ if not rows:
405
+ return []
406
+
407
+ results = [dict(r) for r in rows]
408
+
409
+ # Normalizes BM25: the most negative rank becomes 1.0 and the least negative becomes ~0.0
410
+ min_rank = min(r["rank"] for r in results) # most negative value
411
+ for r in results:
412
+ if min_rank < 0:
413
+ r["bm25_score"] = round(r["rank"] / min_rank, 4)
414
+ else:
415
+ r["bm25_score"] = 1.0
416
+ del r["rank"] # Removes internal SQLite field
417
+
418
+ return results
419
+
420
+ def fts_rebuild(self) -> None:
421
+ """Rebuilds the complete FTS5 index.
422
+
423
+ Use after massive ingestion (concierge mine) to optimize performance.
424
+ """
425
+ def _do(conn: Any) -> None:
426
+ conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');")
427
+
428
+ self._conn.write(_do)
429
+ logger.info("FTS5 index rebuilt successfully.")
430
+
431
+ # ===================================================================
432
+ # 5. WEIGHTED HYBRID SEARCH (Hybrid Search v4)
433
+ # ===================================================================
434
+
435
+ def hybrid_search_score(
436
+ self,
437
+ node_id: int,
438
+ vector_score: float,
439
+ fts_score: float,
440
+ ) -> dict:
441
+ """Calculates final combined score for a node in Hybrid Search v4.
442
+
443
+ Formula (Architecture v3.8):
444
+ score = (0.50 × vector)
445
+ + (0.25 × normalized_fts5)
446
+ + (0.25 × max(recency, centrality))
447
+
448
+ The third component uses max(recency, centrality) to protect
449
+ stable "core code": if a node is old (low recency) but has
450
+ high centrality (many dependents), centrality prevails.
451
+
452
+ Args:
453
+ node_id: Node ID.
454
+ vector_score: Vector similarity score [0, 1] (from pluggable backend).
455
+ fts_score: Normalized BM25 score [0, 1] (from fts_search).
456
+
457
+ Returns:
458
+ Dict with complete breakdown:
459
+ {
460
+ "node_id": int,
461
+ "score_final": float,
462
+ "score_breakdown": {
463
+ "vetorial": float,
464
+ "frequencia": float,
465
+ "recencia": float,
466
+ "centralidade": float
467
+ },
468
+ "is_super_node": bool
469
+ }
470
+ """
471
+ # Calculates individual components
472
+ recency = self.compute_recency_score(node_id)
473
+ centrality = self.compute_centrality(node_id)
474
+
475
+ # max(recency, centrality): protects stable Super-Nodes
476
+ recency_centrality = max(recency, centrality)
477
+
478
+ # Weighted final score
479
+ score_final = (
480
+ self.WEIGHT_VECTOR * vector_score
481
+ + self.WEIGHT_FTS5 * fts_score
482
+ + self.WEIGHT_RECENCY_CENTRALITY * recency_centrality
483
+ )
484
+
485
+ return {
486
+ "node_id": node_id,
487
+ "score_final": round(score_final, 4),
488
+ "score_breakdown": {
489
+ "vetorial": round(vector_score, 4),
490
+ "frequencia": round(fts_score, 4),
491
+ "recencia": round(recency, 4),
492
+ "centralidade": round(centrality, 4),
493
+ },
494
+ "is_super_node": centrality >= 1.0,
495
+ }
496
+
497
+ def hybrid_search_score_batch(
498
+ self,
499
+ candidates: list[dict],
500
+ ) -> list[dict]:
501
+ """Calculates hybrid scores in batch and returns sorted by relevance.
502
+
503
+ Optimized: does centrality and recency queries in batch
504
+ instead of N individual queries.
505
+
506
+ Args:
507
+ candidates: List of dicts, each containing:
508
+ - "node_id" (int)
509
+ - "vector_score" (float) — from vector backend
510
+ - "fts_score" (float) — from fts_search (normalized BM25)
511
+
512
+ Returns:
513
+ List of dicts with score_final and breakdown, sorted DESC by score.
514
+ """
515
+ if not candidates:
516
+ return []
517
+
518
+ node_ids = [c["node_id"] for c in candidates]
519
+
520
+ # Batch queries — 2 SQL queries instead of 2N
521
+ centrality_map = self.compute_centrality_batch(node_ids)
522
+ recency_map = self.compute_recency_batch(node_ids)
523
+
524
+ results = []
525
+ for c in candidates:
526
+ nid = c["node_id"]
527
+ vector_score = c.get("vector_score", 0.0)
528
+ fts_score = c.get("fts_score", 0.0)
529
+ recency = recency_map.get(nid, self.RECENCY_MIN_SCORE)
530
+ centrality = centrality_map.get(nid, 0.0)
531
+
532
+ recency_centrality = max(recency, centrality)
533
+ score_final = (
534
+ self.WEIGHT_VECTOR * vector_score
535
+ + self.WEIGHT_FTS5 * fts_score
536
+ + self.WEIGHT_RECENCY_CENTRALITY * recency_centrality
537
+ )
538
+
539
+ results.append({
540
+ "node_id": nid,
541
+ "score_final": round(score_final, 4),
542
+ "score_breakdown": {
543
+ "vetorial": round(vector_score, 4),
544
+ "frequencia": round(fts_score, 4),
545
+ "recencia": round(recency, 4),
546
+ "centralidade": round(centrality, 4),
547
+ },
548
+ "is_super_node": centrality >= 1.0,
549
+ })
550
+
551
+ # Sorts by score_final DESC (most relevant first)
552
+ results.sort(key=lambda x: x["score_final"], reverse=True)
553
+
554
+ logger.debug(
555
+ "Hybrid batch: %d candidates processed. Top score=%.4f",
556
+ len(results), results[0]["score_final"] if results else 0.0
557
+ )
558
+ return results
559
+
560
+ # ===================================================================
561
+ # 6. RECURSIVE QUERIES (CTE) with loop protection
562
+ # ===================================================================
563
+
564
+ def get_dependency_tree(
565
+ self, start_node_id: int, max_depth: int = 10
566
+ ) -> list[dict]:
567
+ """Dependency tree: source → target (who this node depends on).
568
+
569
+ CTE with depth limit to prevent infinite loops.
570
+
571
+ Args:
572
+ start_node_id: Root node.
573
+ max_depth: Maximum depth (default: 10).
574
+
575
+ Returns:
576
+ List of dicts: id, label, node_type, depth.
577
+ """
578
+ sql = """
579
+ WITH RECURSIVE dep_tree(id, label, node_type, depth) AS (
580
+ SELECT id, label, node_type, 0
581
+ FROM nodes WHERE id = ?
582
+ UNION ALL
583
+ SELECT n.id, n.label, n.node_type, dt.depth + 1
584
+ FROM nodes n
585
+ JOIN edges e ON n.id = e.target_id
586
+ JOIN dep_tree dt ON e.source_id = dt.id
587
+ WHERE dt.depth < ?
588
+ )
589
+ SELECT DISTINCT * FROM dep_tree ORDER BY depth;
590
+ """
591
+ with self._conn.read() as conn:
592
+ rows = conn.execute(sql, (start_node_id, max_depth)).fetchall()
593
+ return [dict(r) for r in rows]
594
+
595
+ def get_reverse_dependency_tree(
596
+ self, start_node_id: int, max_depth: int = 10
597
+ ) -> list[dict]:
598
+ """Reverse dependency tree: who depends ON THIS node (target → source).
599
+
600
+ Args:
601
+ start_node_id: Target node.
602
+ max_depth: Maximum depth.
603
+
604
+ Returns:
605
+ List of dicts: id, label, node_type, depth.
606
+ """
607
+ sql = """
608
+ WITH RECURSIVE rev_tree(id, label, node_type, depth) AS (
609
+ SELECT id, label, node_type, 0
610
+ FROM nodes WHERE id = ?
611
+ UNION ALL
612
+ SELECT n.id, n.label, n.node_type, rt.depth + 1
613
+ FROM nodes n
614
+ JOIN edges e ON n.id = e.source_id
615
+ JOIN rev_tree rt ON e.target_id = rt.id
616
+ WHERE rt.depth < ?
617
+ )
618
+ SELECT DISTINCT * FROM rev_tree ORDER BY depth;
619
+ """
620
+ with self._conn.read() as conn:
621
+ rows = conn.execute(sql, (start_node_id, max_depth)).fetchall()
622
+ return [dict(r) for r in rows]
623
+
624
+ # ===================================================================
625
+ # 7. PROJECT STATISTICS
626
+ # ===================================================================
627
+
628
+ def get_project_stats(self, project_uuid: str) -> dict:
629
+ """Complete statistics of a project via aggregated SQL.
630
+
631
+ Returns:
632
+ Dict with: nodes, nodes_by_type, edges, commits,
633
+ last_commit_at, trajectories, trajectories_active.
634
+ """
635
+ with self._conn.read() as conn:
636
+ # Total node count
637
+ nodes_total = conn.execute(
638
+ "SELECT COUNT(*) AS c FROM nodes WHERE project_uuid = ?",
639
+ (project_uuid,)
640
+ ).fetchone()["c"]
641
+
642
+ # Count by node type
643
+ type_rows = conn.execute(
644
+ """SELECT node_type, COUNT(*) AS c FROM nodes
645
+ WHERE project_uuid = ? GROUP BY node_type""",
646
+ (project_uuid,)
647
+ ).fetchall()
648
+ nodes_by_type = {r["node_type"]: r["c"] for r in type_rows}
649
+
650
+ # Project edges
651
+ edges_count = conn.execute(
652
+ """SELECT COUNT(*) AS c FROM edges e
653
+ JOIN nodes n ON e.source_id = n.id
654
+ WHERE n.project_uuid = ?""",
655
+ (project_uuid,)
656
+ ).fetchone()["c"]
657
+
658
+ # Commits
659
+ commits_count = conn.execute(
660
+ "SELECT COUNT(*) AS c FROM commit_log WHERE project_uuid = ?",
661
+ (project_uuid,)
662
+ ).fetchone()["c"]
663
+
664
+ last_commit = conn.execute(
665
+ "SELECT MAX(created_at) AS last_at FROM commit_log WHERE project_uuid = ?",
666
+ (project_uuid,)
667
+ ).fetchone()["last_at"]
668
+
669
+ # Trajectories
670
+ traj_total = conn.execute(
671
+ "SELECT COUNT(*) AS c FROM trajectories WHERE project_uuid = ?",
672
+ (project_uuid,)
673
+ ).fetchone()["c"]
674
+
675
+ traj_active = conn.execute(
676
+ "SELECT COUNT(*) AS c FROM trajectories WHERE project_uuid = ? AND status = 'ACTIVE'",
677
+ (project_uuid,)
678
+ ).fetchone()["c"]
679
+
680
+ return {
681
+ "nodes": nodes_total,
682
+ "nodes_by_type": nodes_by_type,
683
+ "edges": edges_count,
684
+ "commits": commits_count,
685
+ "last_commit_at": last_commit,
686
+ "trajectories": traj_total,
687
+ "trajectories_active": traj_active,
688
+ }
689
+
690
+ def get_last_commit_phase(self, project_uuid: str) -> Optional[str]:
691
+ """Returns the phase of the project's most recent commit.
692
+
693
+ Returns:
694
+ String of phase (e.g. 'build') or None if no commits.
695
+ """
696
+ with self._conn.read() as conn:
697
+ row = conn.execute(
698
+ """SELECT phase FROM commit_log
699
+ WHERE project_uuid = ?
700
+ ORDER BY created_at DESC LIMIT 1""",
701
+ (project_uuid,)
702
+ ).fetchone()
703
+ return row["phase"] if row else None