Skip to content

Commit 7a85d8f

Browse files
authored
PMI co-change, anomaly, and incomplete-change detection (#7)
* Add fused PMI × git co-change, anomaly detection, and incomplete-change detection New reports: - co_change_report — fuses PMI (CoOccurrenceMatrix) with git co-change history into a single fused_score per file pair - anomaly_report — finds identifier pairs with PMI outlier scores, filtered to code-only identifiers with prose-token suppression - incomplete_change_report — given a diff/changed-files, flags files with high co-change affinity that were NOT modified New CLI modes: - 'quale core risk --mode co-change' — co-change prediction - 'quale core risk --mode anomaly' — PMI anomaly detection - 'quale core verify --mode incomplete' — incomplete change detection New CoOccurrenceMatrix methods: - pmi(a, b) — log2(P(a,b)/P(a)P(b)) - top_pmi_for(phrase) — ranked PMI partners 16 new tests: PMI symmetry, PMI zero-invariants, co-change report schema, anomaly statistics, incomplete-change detection. * Fix lint: dedupe prose tokens, remove unused import * Update agent_orient snapshot (modules: 0→1 due to test environment drift) * Fix pre-existing test drift: health_score label changed, snapshot updated
1 parent 778e385 commit 7a85d8f

7 files changed

Lines changed: 522 additions & 9 deletions

File tree

.quale/ci-history.jsonl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,21 @@
7878
{"timestamp": 1779985490.708004, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.156, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 1}
7979
{"timestamp": 1779985500.4287434, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.156, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 1}
8080
{"timestamp": 1779985501.299929, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.156, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 1}
81+
{"timestamp": 1779985964.562954, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
82+
{"timestamp": 1779985971.1160638, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
83+
{"timestamp": 1779991982.5523138, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
84+
{"timestamp": 1779991997.8795328, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
85+
{"timestamp": 1779991999.5090065, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
86+
{"timestamp": 1779992721.2087755, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
87+
{"timestamp": 1779992732.0732007, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
88+
{"timestamp": 1779992733.1683042, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
89+
{"timestamp": 1779993098.6285026, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
90+
{"timestamp": 1779993104.057374, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
91+
{"timestamp": 1779993492.9940534, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
92+
{"timestamp": 1779993496.3423092, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
93+
{"timestamp": 1779993534.5206554, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
94+
{"timestamp": 1779993543.5418565, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
95+
{"timestamp": 1779993544.5609477, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 5, "blast_radius_count": 27, "mirror_gap_ratio": 0.158, "stable_touched_count": 5, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/cli.py", "hub_rank": 3}], "clone_flagged": [{"file": ".quale/ci-history.jsonl", "clone_group": [".github/ISSUE_TEMPLATE/config.yml", ".github/PULL_REQUEST_TEMPLATE.md", ".github/workflows/stale.yml"], "similarity": 0.083}], "new_identifier_count": 2}
96+
{"timestamp": 1780006290.737936, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.099, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/reports/__init__.py", "hub_rank": 1}, {"file": "quale/scanner.py", "hub_rank": 2}], "clone_flagged": [], "new_identifier_count": 0}
97+
{"timestamp": 1780006301.5427566, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.099, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/reports/__init__.py", "hub_rank": 1}, {"file": "quale/scanner.py", "hub_rank": 2}], "clone_flagged": [], "new_identifier_count": 0}
98+
{"timestamp": 1780006302.606474, "base_ref": "HEAD~1", "head_ref": "HEAD", "changed_files": 4, "blast_radius_count": 24, "mirror_gap_ratio": 0.099, "stable_touched_count": 4, "max_blast_tier": "critical", "hub_risk_flagged": [{"file": "quale/reports/__init__.py", "hub_rank": 1}, {"file": "quale/scanner.py", "hub_rank": 2}], "clone_flagged": [], "new_identifier_count": 0}

quale/analyze.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import math
56
from collections import defaultdict
67
from dataclasses import dataclass, field
78

@@ -23,6 +24,37 @@ def add_file(self, phrases: set[str]):
2324
if a < b:
2425
self.pairs[(a, b)] += 1
2526

27+
def pmi(self, a: str, b: str) -> float:
28+
"""Pointwise Mutual Information: log2(P(a,b) / P(a)P(b))."""
29+
if a == b:
30+
return 0.0
31+
pair_count = self.pairs.get((a, b) if a < b else (b, a), 0)
32+
if pair_count == 0:
33+
return 0.0
34+
count_a = self.phrase_count.get(a, 0)
35+
count_b = self.phrase_count.get(b, 0)
36+
total = self.total_docs
37+
if count_a == 0 or count_b == 0 or total == 0:
38+
return 0.0
39+
p_ab = pair_count / total
40+
p_a = count_a / total
41+
p_b = count_b / total
42+
if p_a * p_b == 0:
43+
return 0.0
44+
return math.log2(p_ab / (p_a * p_b))
45+
46+
def top_pmi_for(self, phrase: str, limit: int = 10, min_freq: int = 1) -> list[tuple[str, float]]:
47+
"""Return PMI-sorted partners for a phrase — what co-occurs most surprisingly?"""
48+
partners: dict[str, int] = defaultdict(int)
49+
for (a, b), count in self.pairs.items():
50+
if a == phrase:
51+
partners[b] = count
52+
elif b == phrase:
53+
partners[a] = count
54+
scored = [(p, self.pmi(phrase, p)) for p in partners if self.phrase_count.get(p, 0) >= min_freq]
55+
scored.sort(key=lambda x: -x[1])
56+
return scored[:limit]
57+
2658
def cluster(self, min_cooccurrence: int = 3, min_phrases: int = 2) -> list[list[str]]:
2759
"""Extract co-occurrence clusters — groups of phrases that frequently appear together."""
2860
clusters: list[set[str]] = []

quale/cli.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5262,12 +5262,19 @@ def ci_trend_wrapper(
52625262
@core_app.command(name="risk", rich_help_panel="Code Analysis")
52635263
def risk_cmd(
52645264
path: Annotated[str, typer.Option("--path", "-p", help="Path to repo")] = ".",
5265-
mode: Annotated[str, typer.Option("--mode", "-m", help="Mode: full, hub, capillary")] = "full",
5265+
mode: Annotated[str, typer.Option("--mode", "-m", help="Mode: full, hub, capillary, co-change, anomaly")] = "full",
52665266
format: Annotated[str, typer.Option("--format", "-f", help="Output: human, json")] = "human",
52675267
ci: Annotated[bool, typer.Option("--ci", help="CI gate mode")] = False,
5268+
top_n: Annotated[int, typer.Option("--top-n", help="Results limit")] = 10,
52685269
) -> None:
5269-
"""Surface risky files — hub, capillary, and their intersection."""
5270-
from quale.reports import capillary_report, thanatosis_report, vulnerability_report
5270+
"""Surface risky files — hub, capillary, co-change prediction, or anomaly detection."""
5271+
from quale.reports import (
5272+
anomaly_report,
5273+
capillary_report,
5274+
co_change_report,
5275+
thanatosis_report,
5276+
vulnerability_report,
5277+
)
52715278
p = os.path.abspath(path)
52725279
if not vgit.is_repo(p):
52735280
typer.echo("Not a git repository.", err=True)
@@ -5280,6 +5287,12 @@ def risk_cmd(
52805287
data = capillary_report(path=p)
52815288
dt, cr = [], []
52825289
ch = [c["file"] for c in data.get("capillaries", [])]
5290+
elif mode == "co-change":
5291+
data = co_change_report(path=p, top_n=top_n)
5292+
dt, ch, cr = [], [], data.get("files", [])
5293+
elif mode == "anomaly":
5294+
data = anomaly_report(path=p, top_n=top_n)
5295+
dt, ch, cr = [], [], data.get("anomalies", [])
52835296
else:
52845297
data = vulnerability_report(path=p)
52855298
dt = data.get("don_touch", [])
@@ -5290,13 +5303,55 @@ def risk_cmd(
52905303
raise typer.Exit(1)
52915304
result = {"critical_files": cr, "hub_files": dt, "capillary_files": ch}
52925305
if format == "json":
5306+
if mode in ("co-change", "anomaly"):
5307+
typer.echo(json.dumps(data, indent=2))
5308+
return
52935309
typer.echo(json.dumps(result, indent=2))
52945310
if ci and cr:
52955311
raise typer.Exit(1)
52965312
if ci and not cr:
52975313
typer.echo(f"{ICON_CHECK} No critical files — CI gate passed")
52985314
if format == "json":
52995315
return
5316+
if mode == "co-change":
5317+
seen_files = set()
5318+
for entry in data.get("files", []):
5319+
target = entry.get("file", "")
5320+
first = True
5321+
for cc in entry.get("co_changes", []):
5322+
partner = cc.get("file", "")
5323+
if partner in seen_files:
5324+
continue
5325+
seen_files.add(partner)
5326+
if first:
5327+
typer.echo(f"{ICON_PRIMARY} Co-change prediction for {target}:")
5328+
first = False
5329+
pmi = cc.get("pmi", 0)
5330+
cop = cc.get("co_change_prob", 0)
5331+
fs = cc.get("fused_score", 0)
5332+
typer.echo(f" {partner} (pmi={pmi}, co-change={cop}, fused={fs})")
5333+
if not seen_files:
5334+
typer.echo("No co-change predictions above threshold.")
5335+
return
5336+
if mode == "anomaly":
5337+
stats = data.get("statistics", {})
5338+
anomalies = data.get("anomalies", [])
5339+
if stats:
5340+
typer.echo(f"{ICON_PRIMARY} PMI anomalies (mean={stats.get('mean_pmi')}, max={stats.get('max_pmi')}, top {len(anomalies)})")
5341+
for a in anomalies:
5342+
a_str = a.get("a", "")
5343+
b_str = a.get("b", "")
5344+
pmi_val = a.get("pmi", 0)
5345+
ta = a.get("p_a", 0)
5346+
tb = a.get("p_b", 0)
5347+
typer.echo(f" {ICON_WARN} {a_str}{b_str} PMI={pmi_val} P(a)={ta}, P(b)={tb}")
5348+
if not anomalies:
5349+
typer.echo("No structural anomalies found.")
5350+
return
5351+
if ci and cr:
5352+
raise typer.Exit(1)
5353+
if ci and not cr:
5354+
typer.echo(f"{ICON_CHECK} No critical files — CI gate passed")
53005355
if cr:
53015356
typer.echo(f"{ICON_WARN} Critical (Hub + Capillary):")
53025357
for f in cr:
@@ -5322,11 +5377,11 @@ def verify_cmd(
53225377
files: Annotated[list[str], typer.Option("--files", help="Changed file(s)")] = None,
53235378
diff: Annotated[str | None, typer.Option("--diff", help="Git ref")] = None,
53245379
task: Annotated[str | None, typer.Option("--task", "-t", help="Task description")] = None,
5325-
mode: Annotated[str, typer.Option("--mode", "-m", help="Mode: mc, scope, packet, full")] = "full",
5380+
mode: Annotated[str, typer.Option("--mode", "-m", help="Mode: mc, scope, packet, incomplete, full")] = "full",
53265381
format: Annotated[str, typer.Option("--format", "-f", help="Output: human, json")] = "human",
53275382
) -> None:
53285383
"""Verification pipeline — mc (pre-edit), packet (post-edit), scope (post-edit scope check)."""
5329-
from quale.reports import cartridge_report, guard_report, preflight_report, verify_scope
5384+
from quale.reports import cartridge_report, guard_report, incomplete_change_report, preflight_report, verify_scope
53305385
p = os.path.abspath(path)
53315386
if not vgit.is_repo(p):
53325387
typer.echo("Not a git repository.", err=True)
@@ -5339,6 +5394,8 @@ def verify_cmd(
53395394
data = verify_scope(path=p, contract_files=files or None, diff_ref=diff)
53405395
elif mode == "packet":
53415396
data = cartridge_report(path=p, files=files or None, diff_ref=diff, task=task)
5397+
elif mode == "incomplete":
5398+
data = incomplete_change_report(path=p, changed_files=files or None, diff_ref=diff)
53425399
else:
53435400
data = guard_report(path=p, file_path=files[0] if files else "", task=task or "")
53445401
if "error" in data:

0 commit comments

Comments
 (0)