feat: CI scoring overhaul — principal roll-up, commit-type filter, new weights

Step 1: principal column + commit_type column in pipeline.db. Static map
populates principal for local agents (rio→m3taversal etc.). VPS agents
(epimetheus, argus) have no principal.

Step 2: _classify_commit_type in merge.py. Pipeline commits (inbox/,
entities/, agents/) get commit_type='pipeline' and skip CI attribution
entirely. Knowledge commits (domains/, core/, foundations/, decisions/)
get full attribution.

Step 3 (Argus): Dashboard has dual view — by-principal (default,
governance) and by-agent (drill-down). Already implemented by Argus.

CI weights updated (Cory-approved):
- Challenger: 0.35 (was 0.20)
- Synthesizer: 0.25 (was 0.15)
- Reviewer: 0.20 (was 0.10)
- Sourcer: 0.15 (unchanged)
- Extractor: 0.05 (was 0.40)

Pentagon-Agent: Epimetheus <3D35839A-7722-4740-B93D-51157F7D5E70>
This commit is contained in:
m3taversal 2026-03-26 14:53:54 +00:00
parent 1dfc6dcc5c
commit cfb80d3496
2 changed files with 180 additions and 31 deletions

View file

@ -180,28 +180,94 @@ def _version_changes(conn, days: int = 30) -> list[dict]:
return changes
def _contributor_leaderboard(conn, limit: int = 20) -> list[dict]:
"""Top contributors by CI score."""
def _has_column(conn, table: str, column: str) -> bool:
"""Check if a column exists in a table (graceful schema migration support)."""
cols = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(c["name"] == column for c in cols)
def _contributor_leaderboard(conn, limit: int = 20, view: str = "principal") -> list[dict]:
"""Top contributors by CI score.
view="agent" one row per contributor handle (original behavior)
view="principal" rolls up agent contributions to their principal (human)
"""
has_principal = _has_column(conn, "contributors", "principal")
rows = conn.execute(
"SELECT handle, tier, claims_merged, sourcer_count, extractor_count, "
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution "
"FROM contributors ORDER BY claims_merged DESC LIMIT ?",
(limit,),
"challenger_count, synthesizer_count, reviewer_count, domains, last_contribution"
+ (", principal" if has_principal else "") +
" FROM contributors ORDER BY claims_merged DESC",
).fetchall()
weights = {"sourcer": 0.15, "extractor": 0.40, "challenger": 0.20, "synthesizer": 0.15, "reviewer": 0.10}
result = []
for r in rows:
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
result.append({
"handle": r["handle"],
"tier": r["tier"],
"claims_merged": r["claims_merged"] or 0,
"ci": round(ci, 2),
"domains": json.loads(r["domains"]) if r["domains"] else [],
"last_contribution": r["last_contribution"],
})
return sorted(result, key=lambda x: x["ci"], reverse=True)
# Weights reward quality over volume (Cory-approved)
weights = {"sourcer": 0.15, "extractor": 0.05, "challenger": 0.35, "synthesizer": 0.25, "reviewer": 0.20}
role_keys = list(weights.keys())
if view == "principal" and has_principal:
# Aggregate by principal — agents with a principal roll up to the human
buckets: dict[str, dict] = {}
for r in rows:
principal = r["principal"]
key = principal if principal else r["handle"]
if key not in buckets:
buckets[key] = {
"handle": key,
"tier": r["tier"],
"claims_merged": 0,
"domains": set(),
"last_contribution": None,
"agents": [],
**{f"{role}_count": 0 for role in role_keys},
}
b = buckets[key]
b["claims_merged"] += r["claims_merged"] or 0
for role in role_keys:
b[f"{role}_count"] += r[f"{role}_count"] or 0
if r["domains"]:
b["domains"].update(json.loads(r["domains"]))
if r["last_contribution"]:
if not b["last_contribution"] or r["last_contribution"] > b["last_contribution"]:
b["last_contribution"] = r["last_contribution"]
# Upgrade tier (veteran > contributor > new)
tier_rank = {"veteran": 2, "contributor": 1, "new": 0}
if tier_rank.get(r["tier"], 0) > tier_rank.get(b["tier"], 0):
b["tier"] = r["tier"]
if principal:
b["agents"].append(r["handle"])
result = []
for b in buckets.values():
ci = sum(b[f"{role}_count"] * w for role, w in weights.items())
result.append({
"handle": b["handle"],
"tier": b["tier"],
"claims_merged": b["claims_merged"],
"ci": round(ci, 2),
"domains": sorted(b["domains"])[:5],
"last_contribution": b["last_contribution"],
"agents": b["agents"],
})
else:
# By-agent view (original behavior)
result = []
for r in rows:
ci = sum((r[f"{role}_count"] or 0) * w for role, w in weights.items())
entry = {
"handle": r["handle"],
"tier": r["tier"],
"claims_merged": r["claims_merged"] or 0,
"ci": round(ci, 2),
"domains": json.loads(r["domains"]) if r["domains"] else [],
"last_contribution": r["last_contribution"],
}
if has_principal:
entry["principal"] = r["principal"]
result.append(entry)
result = sorted(result, key=lambda x: x["ci"], reverse=True)
return result[:limit]
# ─── Vital signs (Vida's five) ───────────────────────────────────────────────
@ -386,7 +452,8 @@ async def handle_dashboard(request):
snapshots = _snapshot_history(conn, days=7)
changes = _version_changes(conn, days=30)
vital_signs = _compute_vital_signs(conn)
contributors = _contributor_leaderboard(conn, limit=10)
contributors_principal = _contributor_leaderboard(conn, limit=10, view="principal")
contributors_agent = _contributor_leaderboard(conn, limit=10, view="agent")
except sqlite3.Error as e:
return web.Response(
text=_render_error(f"Pipeline database unavailable: {e}"),
@ -394,7 +461,7 @@ async def handle_dashboard(request):
status=503,
)
now = datetime.now(timezone.utc)
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now)
html = _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now)
return web.Response(text=html, content_type="text/html")
@ -420,10 +487,19 @@ async def handle_api_vital_signs(request):
async def handle_api_contributors(request):
"""GET /api/contributors — contributor leaderboard."""
"""GET /api/contributors — contributor leaderboard.
Query params:
limit: max entries (default 50)
view: "principal" (default, rolls up agents) or "agent" (one row per handle)
"""
conn = _conn(request)
limit = int(request.query.get("limit", "50"))
return web.json_response({"contributors": _contributor_leaderboard(conn, limit)})
view = request.query.get("view", "principal")
if view not in ("principal", "agent"):
view = "principal"
contributors = _contributor_leaderboard(conn, limit, view=view)
return web.json_response({"contributors": contributors, "view": view})
async def handle_api_domains(request):
@ -445,7 +521,7 @@ def _render_error(message: str) -> str:
</head><body><div class="err"><h1>Argus</h1><p>{message}</p><p>Check if <code>teleo-pipeline.service</code> is running and pipeline.db exists.</p></div></body></html>"""
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, now) -> str:
def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors_principal, contributors_agent, now) -> str:
"""Render the full operational dashboard as HTML with Chart.js."""
# Prepare chart data
@ -532,12 +608,23 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
total = sum(statuses.values())
domain_rows += f"<tr><td>{domain}</td><td>{total}</td><td class='green'>{m}</td><td class='red'>{c}</td><td>{o}</td></tr>"
# Contributor rows
contributor_rows = "".join(
f'<tr><td>{c["handle"]}</td><td>{c["tier"]}</td>'
# Contributor rows — principal view (default)
principal_rows = "".join(
f'<tr><td>{c["handle"]}'
+ (f'<span style="color:#8b949e;font-size:11px"> ({", ".join(c["agents"])})</span>' if c.get("agents") else "")
+ f'</td><td>{c["tier"]}</td>'
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
for c in contributors[:10]
for c in contributors_principal[:10]
)
# Contributor rows — agent view
agent_rows = "".join(
f'<tr><td>{c["handle"]}'
+ (f'<span style="color:#8b949e;font-size:11px"> → {c["principal"]}</span>' if c.get("principal") else "")
+ f'</td><td>{c["tier"]}</td>'
f'<td>{c["claims_merged"]}</td><td>{c["ci"]}</td>'
f'<td>{", ".join(c["domains"][:3]) if c["domains"] else "-"}</td></tr>'
for c in contributors_agent[:10]
)
# Breaker status
@ -740,11 +827,21 @@ def _render_dashboard(metrics, snapshots, changes, vital_signs, contributors, no
</div>
</div>
<div class="section">
<div class="section-title">Top Contributors (by CI)</div>
<div class="section-title" style="display:flex;align-items:center;gap:12px">
Top Contributors (by CI)
<span style="font-size:11px;font-weight:400">
<button id="btn-principal" onclick="toggleContribView('principal')" style="background:#21262d;color:#58a6ff;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Human</button>
<button id="btn-agent" onclick="toggleContribView('agent')" style="background:transparent;color:#8b949e;border:1px solid #30363d;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11px">By Agent</button>
</span>
</div>
<div class="card">
<table>
<tr><th>Handle</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
{contributor_rows if contributor_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
<table id="contrib-principal">
<tr><th>Contributor</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
{principal_rows if principal_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
</table>
<table id="contrib-agent" style="display:none">
<tr><th>Agent</th><th>Tier</th><th>Claims</th><th>CI</th><th>Domains</th></tr>
{agent_rows if agent_rows else "<tr><td colspan='5' style='color:#8b949e'>No contributors yet</td></tr>"}
</table>
</div>
</div>
@ -909,6 +1006,28 @@ new Chart(document.getElementById('originChart'), {{
}});
}} // end if (timestamps.length > 0)
function toggleContribView(view) {{
const principal = document.getElementById('contrib-principal');
const agent = document.getElementById('contrib-agent');
const btnP = document.getElementById('btn-principal');
const btnA = document.getElementById('btn-agent');
if (view === 'agent') {{
principal.style.display = 'none';
agent.style.display = '';
btnA.style.background = '#21262d';
btnA.style.color = '#58a6ff';
btnP.style.background = 'transparent';
btnP.style.color = '#8b949e';
}} else {{
principal.style.display = '';
agent.style.display = 'none';
btnP.style.background = '#21262d';
btnP.style.color = '#58a6ff';
btnA.style.background = 'transparent';
btnA.style.color = '#8b949e';
}}
}}
</script>
</body></html>"""

View file

@ -409,11 +409,33 @@ async def _delete_remote_branch(branch: str):
# --- Contributor attribution ---
def _classify_commit_type(diff: str) -> str:
"""Classify a PR as 'knowledge' or 'pipeline' by files changed.
Knowledge: claims, decisions, core, foundations (full CI weight)
Pipeline: inbox, entities, agents, archive (zero CI weight)
"""
knowledge_prefixes = ("domains/", "core/", "foundations/", "decisions/")
pipeline_prefixes = ("inbox/", "entities/", "agents/")
has_knowledge = False
for line in diff.split("\n"):
if line.startswith("+++ b/") or line.startswith("--- a/"):
path = line.split("/", 1)[1] if "/" in line else ""
if any(path.startswith(p) for p in knowledge_prefixes):
has_knowledge = True
break
return "knowledge" if has_knowledge else "pipeline"
async def _record_contributor_attribution(conn, pr_number: int, branch: str):
"""Record contributor attribution after a successful merge.
Parses git trailers and claim frontmatter to identify contributors
and their roles. Upserts into contributors table.
Pipeline commits (inbox/, entities/, agents/) get commit_type='pipeline'
and don't increment role counts.
"""
import re as _re
from datetime import date as _date, datetime as _dt
@ -425,6 +447,14 @@ async def _record_contributor_attribution(conn, pr_number: int, branch: str):
if not diff:
return
# Classify commit type — pipeline commits don't count toward CI
commit_type = _classify_commit_type(diff)
conn.execute("UPDATE prs SET commit_type = ? WHERE number = ?", (commit_type, pr_number))
if commit_type == "pipeline":
logger.info("PR #%d: pipeline commit — skipping CI attribution", pr_number)
return
# Parse Pentagon-Agent trailer from branch commit messages
agents_found: set[str] = set()
rc, log_output = await _git(