circle-ir-ai 2.21.0 → 2.26.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/dist/agents/mastra/workflow.d.ts.map +1 -1
  3. package/dist/agents/mastra/workflow.js +41 -1
  4. package/dist/agents/mastra/workflow.js.map +1 -1
  5. package/dist/cache/classification-cache.d.ts +27 -2
  6. package/dist/cache/classification-cache.d.ts.map +1 -1
  7. package/dist/cache/classification-cache.js +37 -5
  8. package/dist/cache/classification-cache.js.map +1 -1
  9. package/dist/cache/discovery-cache.d.ts +71 -0
  10. package/dist/cache/discovery-cache.d.ts.map +1 -0
  11. package/dist/cache/discovery-cache.js +95 -0
  12. package/dist/cache/discovery-cache.js.map +1 -0
  13. package/dist/cache/tree-cache.d.ts +111 -0
  14. package/dist/cache/tree-cache.d.ts.map +1 -0
  15. package/dist/cache/tree-cache.js +171 -0
  16. package/dist/cache/tree-cache.js.map +1 -0
  17. package/dist/cache/verify-cache.d.ts +60 -0
  18. package/dist/cache/verify-cache.d.ts.map +1 -0
  19. package/dist/cache/verify-cache.js +103 -0
  20. package/dist/cache/verify-cache.js.map +1 -0
  21. package/dist/cluster/cluster.d.ts +174 -0
  22. package/dist/cluster/cluster.d.ts.map +1 -0
  23. package/dist/cluster/cluster.js +392 -0
  24. package/dist/cluster/cluster.js.map +1 -0
  25. package/dist/cluster/index.d.ts +11 -0
  26. package/dist/cluster/index.d.ts.map +1 -0
  27. package/dist/cluster/index.js +10 -0
  28. package/dist/cluster/index.js.map +1 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +4 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/llm/batch-classifier.d.ts.map +1 -1
  34. package/dist/llm/batch-classifier.js +13 -4
  35. package/dist/llm/batch-classifier.js.map +1 -1
  36. package/dist/llm/call-logger.d.ts +36 -0
  37. package/dist/llm/call-logger.d.ts.map +1 -1
  38. package/dist/llm/call-logger.js +36 -0
  39. package/dist/llm/call-logger.js.map +1 -1
  40. package/dist/llm/discovery.d.ts +1 -1
  41. package/dist/llm/discovery.d.ts.map +1 -1
  42. package/dist/llm/discovery.js +39 -0
  43. package/dist/llm/discovery.js.map +1 -1
  44. package/dist/llm/verification.d.ts +1 -1
  45. package/dist/llm/verification.d.ts.map +1 -1
  46. package/dist/llm/verification.js +39 -0
  47. package/dist/llm/verification.js.map +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Verify Cache
3
+ *
4
+ * Persistent on-disk cache for LLM verification batch results
5
+ * (cognium-ai#152, P1 of epic #155). Sits in front of the in-memory
6
+ * `_verifierDedupCache` from verification.ts (#138 step 3) so that
7
+ * identical `(file SHA, pair-set)` requests survive across processes,
8
+ * CI runs, and developer iterations — not just within one process.
9
+ *
10
+ * Built on top of `ClassificationCache<BatchVerificationResult>` (which
11
+ * already carries the cognium-ai#151 model-fingerprint key dimension),
12
+ * with verify-specific key construction:
13
+ *
14
+ * content = SHA-256(code) + '\\0' + pairsFingerprint
15
+ * classifier = 'verify-batch'
16
+ * fingerprint = getPhaseModelFingerprint(config) // includes verify model
17
+ *
18
+ * Cache directory: `.cognium/cache/verify/` (separate from the
19
+ * discovery-phase `.cognium/cache/` so a `rm -rf .cognium/cache/verify/`
20
+ * only nukes verifier entries).
21
+ *
22
+ * Invalidation matrix (acceptance criteria #152):
23
+ * - Edit one file in a project → only that file's entries invalidate
24
+ * (file SHA-256 is part of the cache content key)
25
+ * - Add a new sink to the engine → existing entries still hit if the
26
+ * new sink doesn't change the pair-set for that file
27
+ * - Change verification model → entire cache invalidates
28
+ * (model fingerprint is part of the singleton key)
29
+ *
30
+ * Env toggles:
31
+ * LLM_VERIFY_CACHE=0 → disable persistent verify cache (in-memory
32
+ * dedup from #138 still applies)
33
+ */
34
+ import * as crypto from 'crypto';
35
+ import * as path from 'path';
36
+ import { ClassificationCache } from './classification-cache.js';
37
+ import { getDefaultLLMConfig, getPhaseModelFingerprint } from '../llm/config.js';
38
+ const VERIFY_CACHE_SUBDIR = path.join('.cognium', 'cache', 'verify');
39
+ /**
40
+ * Module-scoped singleton — distinct from the discovery-phase singleton
41
+ * in `classification-cache.ts` so the two phases can carry different
42
+ * model fingerprints (e.g. cheap discovery + strong verifier) without
43
+ * thrashing each other's cache instance.
44
+ */
45
+ let _instance = null;
46
+ let _instanceFingerprint = '';
47
+ /**
48
+ * Get the verify-phase persistent cache, keyed by per-phase model
49
+ * fingerprint. When `LLM_VERIFY_CACHE=0` returns a disabled cache that
50
+ * no-ops on get/set (callers don't need to branch).
51
+ */
52
+ export function getVerifyCache(config = getDefaultLLMConfig()) {
53
+ const enabled = process.env.LLM_VERIFY_CACHE !== '0';
54
+ const fingerprint = getPhaseModelFingerprint(config);
55
+ if (!_instance || _instanceFingerprint !== fingerprint || _instance.getModelFingerprint() !== fingerprint) {
56
+ _instance = new ClassificationCache({
57
+ cacheDir: path.join(process.cwd(), VERIFY_CACHE_SUBDIR),
58
+ modelFingerprint: fingerprint,
59
+ enabled,
60
+ });
61
+ _instanceFingerprint = fingerprint;
62
+ }
63
+ return _instance;
64
+ }
65
+ /** Reset the module-scoped singleton — test-only. */
66
+ export function resetVerifyCache() {
67
+ _instance = null;
68
+ _instanceFingerprint = '';
69
+ }
70
+ /**
71
+ * Stable sort over the source/sink pair-set; never mutates the caller's
72
+ * arrays. Mirrors `_serializeVerifierPairs` in verification.ts so
73
+ * persistent + in-memory dedup agree on what counts as "the same call".
74
+ */
75
+ function serializeVerifyPairs(input) {
76
+ const sources = [...input.sources]
77
+ .map((s) => `${s.line}|${s.type}|${s.variable ?? ''}`)
78
+ .sort();
79
+ const sinks = [...input.sinks]
80
+ .map((s) => `${s.line}|${s.type}|${s.cwe}|${s.method ?? ''}`)
81
+ .sort();
82
+ return JSON.stringify({ sources, sinks });
83
+ }
84
+ /**
85
+ * Compute the `(content, classifier)` tuple for the underlying
86
+ * ClassificationCache. The fingerprint dimension is folded in by the
87
+ * cache itself (see classification-cache.ts:computeKey).
88
+ *
89
+ * Components:
90
+ * - file SHA-256 — file edit invalidates only that file's entry
91
+ * - pairs fingerprint — sort-invariant over the source/sink set
92
+ * - classifier 'verify-batch' — namespace for future
93
+ * 'verify-pair' / 'verify-flow' tiers without collision
94
+ */
95
+ export function computeVerifyCacheKey(input) {
96
+ const fileSha = crypto.createHash('sha256').update(input.code).digest('hex');
97
+ const pairsFp = crypto.createHash('sha256').update(serializeVerifyPairs(input)).digest('hex');
98
+ return {
99
+ content: `${fileSha}\0${pairsFp}`,
100
+ classifier: 'verify-batch',
101
+ };
102
+ }
103
+ //# sourceMappingURL=verify-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-cache.js","sourceRoot":"","sources":["../../src/cache/verify-cache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAkB,MAAM,kBAAkB,CAAC;AAGjG,MAAM,mBAAmB,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;AAErE;;;;;GAKG;AACH,IAAI,SAAS,GAAwD,IAAI,CAAC;AAC1E,IAAI,oBAAoB,GAAG,EAAE,CAAC;AAE9B;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,SAAoB,mBAAmB,EAAE;IAEzC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,gBAAgB,KAAK,GAAG,CAAC;IACrD,MAAM,WAAW,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IAErD,IAAI,CAAC,SAAS,IAAI,oBAAoB,KAAK,WAAW,IAAI,SAAS,CAAC,mBAAmB,EAAE,KAAK,WAAW,EAAE,CAAC;QAC1G,SAAS,GAAG,IAAI,mBAAmB,CAA0B;YAC3D,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,mBAAmB,CAAC;YACvD,gBAAgB,EAAE,WAAW;YAC7B,OAAO;SACR,CAAC,CAAC;QACH,oBAAoB,GAAG,WAAW,CAAC;IACrC,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,gBAAgB;IAC9B,SAAS,GAAG,IAAI,CAAC;IACjB,oBAAoB,GAAG,EAAE,CAAC;AAC5B,CAAC;AAED;;;;GAIG;AACH,SAAS,oBAAoB,CAAC,KAA6B;IACzD,MAAM,OAAO,GAAG,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC;SAC/B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,QAAQ,IAAI,EAAE,EAAE,CAAC;SACrD,IAAI,EAAE,CAAC;IACV,MAAM,KAAK,GAAG,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC;SAC3B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC;SAC5D,IAAI,EAAE,CAAC;IACV,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;AAC5C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAA6B;IAE7B,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC7E,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9F,OAAO;QACL,OAAO,EAAE,GAAG,OAAO,KAAK,OAAO,EAAE;QACjC,UAAU,EAAE,cAAc;KAC3B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Cluster Layer — Foundation
3
+ *
4
+ * Top-of-pipeline file clustering (cognium-ai#154, P4 of epic #155).
5
+ * Groups files in a repo by their static structural fingerprint (CK
6
+ * metrics + import/annotation set) so downstream passes can apply
7
+ * per-cluster policy bundles ("library bootstrap" vs "controller
8
+ * surface" vs "data-access shim") instead of one-size-fits-all
9
+ * discovery/verification.
10
+ *
11
+ * This file ships the **foundation only**:
12
+ * - `extractClusterVector(circleIr)` — pull CK vector from analyze() output
13
+ * - `clusterFiles(inputs)` — deterministic k-medoids, returns
14
+ * `Map<filePath, clusterId>` per acceptance #1
15
+ * - distance functions (Euclidean on normalized CK + Jaccard on
16
+ * imports/annotations) + the k-medoids loop itself
17
+ * - `ClusterPolicy` type so downstream wiring can land in a
18
+ * follow-up without re-declaring shape
19
+ *
20
+ * **Explicitly out of scope here** (tracked as follow-up tickets):
21
+ * - `meta.json.files[*].clusterId` field — needs scan-emit schema
22
+ * migration (Acceptance #3)
23
+ * - top-100 HIGH/CRIT reduction validation (Acceptance #4)
24
+ * - CWE-Bench-Java regression check (Acceptance #5)
25
+ * - `--cluster-policy <profile>` CLI flag in cognium-ai
26
+ * (Acceptance #6)
27
+ * - LLM cluster labeling pass (optional future per the issue)
28
+ *
29
+ * The split is deliberate: P1-P3 of epic #155 were single-shot
30
+ * cache wins; P4's policy bundles + the two benchmark validation
31
+ * gates need empirical tuning that can't complete in a single
32
+ * release cycle. Foundation-first lets cognium-ai start consuming
33
+ * `clusterFiles()` as an opaque grouping signal while the policy
34
+ * resolver is iterated separately.
35
+ *
36
+ * **Determinism:** k-medoids is seeded from a digest of (sorted
37
+ * file paths || vector signature) so re-runs on the same input
38
+ * produce the same cluster assignments. Tie-breaking on equal
39
+ * distances falls back to lexicographic path order.
40
+ */
41
+ import type { CircleIR } from 'circle-ir';
42
+ /**
43
+ * Per-file structural fingerprint used as the clustering signal.
44
+ * CK metrics default to 0 when circle-ir's metric pass didn't run
45
+ * (e.g. procedural-only files, languages without classes).
46
+ */
47
+ export interface ClusterVector {
48
+ cbo: number;
49
+ rfc: number;
50
+ lcom: number;
51
+ dit: number;
52
+ noc: number;
53
+ wmc: number;
54
+ imports: string[];
55
+ publicSurface: number;
56
+ annotations: string[];
57
+ }
58
+ export interface ClusterInput {
59
+ filePath: string;
60
+ vector: ClusterVector;
61
+ }
62
+ export interface ClusterAssignment {
63
+ filePath: string;
64
+ clusterId: number;
65
+ }
66
+ export interface ClusterResult {
67
+ k: number;
68
+ assignments: ClusterAssignment[];
69
+ /** Medoid file path per cluster id (index = clusterId). */
70
+ medoids: string[];
71
+ /** Iterations until convergence (for tuning visibility). */
72
+ iterations: number;
73
+ }
74
+ /**
75
+ * Downstream policy bundle attached to a cluster. Shape locked here
76
+ * so wiring tickets can land without re-negotiating the contract;
77
+ * default policy ("application", everything on) is what consumers
78
+ * should apply until cluster-specific overrides ship.
79
+ */
80
+ export type ContextProfile = 'application' | 'library' | 'framework' | 'tool';
81
+ export interface ClusterPolicy {
82
+ contextProfile: ContextProfile;
83
+ /** Discovery source kinds permitted; empty array = all. */
84
+ sourceWhitelist: string[];
85
+ /** Verification sink classes permitted; empty array = all. */
86
+ sinkWhitelist: string[];
87
+ /** When false, downstream LLM passes skip this cluster entirely. */
88
+ llmEnabled: boolean;
89
+ }
90
+ export declare const DEFAULT_CLUSTER_POLICY: ClusterPolicy;
91
+ /**
92
+ * Extract a ClusterVector from a single-file `CircleIR` analyze()
93
+ * result. Missing metrics default to 0 — the cluster vector is
94
+ * structural, so a procedural file with no CK numbers will land
95
+ * in the "low-complexity" cluster naturally (it's a valid signal,
96
+ * not missing data).
97
+ */
98
+ export declare function extractClusterVector(circleIr: CircleIR): ClusterVector;
99
+ /**
100
+ * Per-axis max-normalize the CK + publicSurface scalars across the
101
+ * input set. Max-normalize (rather than z-score) is deliberate:
102
+ * cluster boundaries should be robust to outliers (one god-class
103
+ * with WMC=400 shouldn't squash everything else into the same
104
+ * cluster), and max-normalize gives a clean [0,1] domain that
105
+ * combines naturally with Jaccard.
106
+ *
107
+ * Returns the maxima so callers can normalize new vectors against
108
+ * the same scale (e.g. for the k-medoids loop on the same set).
109
+ */
110
+ export declare function computeNormalization(inputs: ClusterInput[]): {
111
+ cboMax: number;
112
+ rfcMax: number;
113
+ lcomMax: number;
114
+ ditMax: number;
115
+ nocMax: number;
116
+ wmcMax: number;
117
+ publicSurfaceMax: number;
118
+ };
119
+ /** Euclidean distance on the max-normalized CK + publicSurface scalars. */
120
+ export declare function euclideanCkDistance(a: ClusterVector, b: ClusterVector, norm: ReturnType<typeof computeNormalization>): number;
121
+ /**
122
+ * Jaccard distance on two string sets (1 - |intersection|/|union|).
123
+ * Returns 0 when both sets are empty (semantically: nothing
124
+ * distinguishes them on this axis).
125
+ */
126
+ export declare function jaccardDistance(a: string[], b: string[]): number;
127
+ /**
128
+ * Combined per-pair distance. Weights mirror the issue's signal
129
+ * vector ordering (CK is the primary signal; imports + annotations
130
+ * are tiebreakers). Total is in [0,1] so it composes cleanly with
131
+ * other metric-space algorithms.
132
+ *
133
+ * Weights chosen to sum to 1:
134
+ * 0.6 — CK + publicSurface Euclidean (clamped to 1 by sqrt of 7
135
+ * normalized components, since each (a-b)/max is in [-1,1])
136
+ * 0.2 — imports Jaccard
137
+ * 0.2 — annotations Jaccard
138
+ *
139
+ * Euclidean is divided by sqrt(7) to renormalize to [0,1] before
140
+ * weighting — without this, the CK term can exceed 1 and the total
141
+ * stops being a proper [0,1] distance.
142
+ */
143
+ export declare function mixedDistance(a: ClusterVector, b: ClusterVector, norm: ReturnType<typeof computeNormalization>): number;
144
+ interface KMedoidsOptions {
145
+ /** Desired cluster count. Clamped to `[1, inputs.length]`. */
146
+ k: number;
147
+ /** Maximum iterations before bailing. Default 50. */
148
+ maxIter?: number;
149
+ /** Override the deterministic seed (test-only). */
150
+ seed?: number;
151
+ }
152
+ /**
153
+ * PAM-lite k-medoids on `inputs`.
154
+ *
155
+ * 1. Seed selection ("build" phase): pick the medoid that minimizes
156
+ * total distance to all points, then iteratively pick the point
157
+ * that maximizes minimum distance to existing medoids. This is
158
+ * deterministic given the seed and degenerates gracefully when
159
+ * `inputs.length <= k`.
160
+ * 2. Assignment: each point joins its closest medoid (lex path
161
+ * tiebreak on equal distances).
162
+ * 3. Swap phase: for each cluster, find the point that minimizes
163
+ * intra-cluster distance and promote it to medoid. Repeat until
164
+ * no medoid changes (or `maxIter` hits).
165
+ *
166
+ * Complexity is O(n^2 * iter) on the distance matrix. For the
167
+ * top-100 sweep (n ≈ 5000 per repo at the high end) this is ~25M
168
+ * pairwise computations per iteration — fine for the <2s budget
169
+ * in Acceptance #2 because distances are scalar arithmetic on
170
+ * a 9-dim vector.
171
+ */
172
+ export declare function clusterFiles(inputs: ClusterInput[], options: KMedoidsOptions): ClusterResult;
173
+ export {};
174
+ //# sourceMappingURL=cluster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster.d.ts","sourceRoot":"","sources":["../../src/cluster/cluster.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAe,MAAM,WAAW,CAAC;AAMvD;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,CAAC,EAAE,MAAM,CAAC;IACV,WAAW,EAAE,iBAAiB,EAAE,CAAC;IACjC,2DAA2D;IAC3D,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG,MAAM,CAAC;AAE9E,MAAM,WAAW,aAAa;IAC5B,cAAc,EAAE,cAAc,CAAC;IAC/B,2DAA2D;IAC3D,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,8DAA8D;IAC9D,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,oEAAoE;IACpE,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,eAAO,MAAM,sBAAsB,EAAE,aAKpC,CAAC;AAsBF;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,aAAa,CAyDtE;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;CAC1B,CAYA;AAED,2EAA2E;AAC3E,wBAAgB,mBAAmB,CACjC,CAAC,EAAE,aAAa,EAChB,CAAC,EAAE,aAAa,EAChB,IAAI,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,GAC5C,MAAM,CAWR;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAShE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,CAAC,EAAE,aAAa,EAChB,CAAC,EAAE,aAAa,EAChB,IAAI,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,GAC5C,MAAM,CAKR;AAwCD,UAAU,eAAe;IACvB,8DAA8D;IAC9D,CAAC,EAAE,MAAM,CAAC;IACV,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mDAAmD;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,YAAY,EAAE,EACtB,OAAO,EAAE,eAAe,GACvB,aAAa,CAsJf"}
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Cluster Layer — Foundation
3
+ *
4
+ * Top-of-pipeline file clustering (cognium-ai#154, P4 of epic #155).
5
+ * Groups files in a repo by their static structural fingerprint (CK
6
+ * metrics + import/annotation set) so downstream passes can apply
7
+ * per-cluster policy bundles ("library bootstrap" vs "controller
8
+ * surface" vs "data-access shim") instead of one-size-fits-all
9
+ * discovery/verification.
10
+ *
11
+ * This file ships the **foundation only**:
12
+ * - `extractClusterVector(circleIr)` — pull CK vector from analyze() output
13
+ * - `clusterFiles(inputs)` — deterministic k-medoids, returns
14
+ * `Map<filePath, clusterId>` per acceptance #1
15
+ * - distance functions (Euclidean on normalized CK + Jaccard on
16
+ * imports/annotations) + the k-medoids loop itself
17
+ * - `ClusterPolicy` type so downstream wiring can land in a
18
+ * follow-up without re-declaring shape
19
+ *
20
+ * **Explicitly out of scope here** (tracked as follow-up tickets):
21
+ * - `meta.json.files[*].clusterId` field — needs scan-emit schema
22
+ * migration (Acceptance #3)
23
+ * - top-100 HIGH/CRIT reduction validation (Acceptance #4)
24
+ * - CWE-Bench-Java regression check (Acceptance #5)
25
+ * - `--cluster-policy <profile>` CLI flag in cognium-ai
26
+ * (Acceptance #6)
27
+ * - LLM cluster labeling pass (optional future per the issue)
28
+ *
29
+ * The split is deliberate: P1-P3 of epic #155 were single-shot
30
+ * cache wins; P4's policy bundles + the two benchmark validation
31
+ * gates need empirical tuning that can't complete in a single
32
+ * release cycle. Foundation-first lets cognium-ai start consuming
33
+ * `clusterFiles()` as an opaque grouping signal while the policy
34
+ * resolver is iterated separately.
35
+ *
36
+ * **Determinism:** k-medoids is seeded from a digest of (sorted
37
+ * file paths || vector signature) so re-runs on the same input
38
+ * produce the same cluster assignments. Tie-breaking on equal
39
+ * distances falls back to lexicographic path order.
40
+ */
41
+ import * as crypto from 'crypto';
42
+ export const DEFAULT_CLUSTER_POLICY = {
43
+ contextProfile: 'application',
44
+ sourceWhitelist: [],
45
+ sinkWhitelist: [],
46
+ llmEnabled: true,
47
+ };
48
+ // ───────────────────────────────────────────────────────────────────
49
+ // CK vector extraction
50
+ // ───────────────────────────────────────────────────────────────────
51
+ /**
52
+ * Names emitted by circle-ir's CK metric passes. Mirrored from
53
+ * the JSDoc on `MetricValue` in circle-ir's public type surface
54
+ * (canonical: "CBO" | "RFC" | "LCOM" | "DIT" | "NOC" | "WMC").
55
+ * Matching is case-insensitive because metric-pass authors have
56
+ * historically used both casings.
57
+ */
58
+ const CK_METRIC_KEYS = ['CBO', 'RFC', 'LCOM', 'DIT', 'NOC', 'WMC'];
59
+ function findMetric(metrics, name) {
60
+ if (!metrics)
61
+ return 0;
62
+ const lower = name.toLowerCase();
63
+ const hit = metrics.find((m) => m.name.toLowerCase() === lower);
64
+ return hit ? hit.value : 0;
65
+ }
66
+ /**
67
+ * Extract a ClusterVector from a single-file `CircleIR` analyze()
68
+ * result. Missing metrics default to 0 — the cluster vector is
69
+ * structural, so a procedural file with no CK numbers will land
70
+ * in the "low-complexity" cluster naturally (it's a valid signal,
71
+ * not missing data).
72
+ */
73
+ export function extractClusterVector(circleIr) {
74
+ const metrics = circleIr.metrics?.metrics;
75
+ // Import package signatures — keep top-2 segments (e.g.
76
+ // `java.util.List` → `java.util`) so unrelated imports of
77
+ // different leaf types in the same package don't artificially
78
+ // increase Jaccard distance. circle-ir's `ImportInfo` carries
79
+ // `from_package` (the canonical package path) and falls back to
80
+ // `imported_name` when `from_package` is null (wildcard / default
81
+ // imports).
82
+ const imports = Array.from(new Set((circleIr.imports ?? [])
83
+ .map((imp) => {
84
+ const raw = imp.from_package ?? imp.imported_name ?? '';
85
+ const parts = raw.split('.');
86
+ return parts.length >= 2 ? parts.slice(0, 2).join('.') : raw;
87
+ })
88
+ .filter((s) => s.length > 0)));
89
+ // Class-level annotations (Java: `@RestController`, `@Service`,
90
+ // `@Entity`; Python: `@app.route` would surface here if circle-ir
91
+ // attaches it to the class node). Pulled from `types[*].annotations`.
92
+ const annotations = Array.from(new Set((circleIr.types ?? [])
93
+ .flatMap((t) => t.annotations ?? [])
94
+ .filter((s) => typeof s === 'string' && s.length > 0)));
95
+ // Public surface = count of types + methods that aren't explicitly
96
+ // marked private. `TypeInfo` has no visibility field in circle-ir's
97
+ // surface (all top-level types count as "public-ish" for clustering
98
+ // purposes); methods carry `modifiers: string[]` from which we pull
99
+ // the visibility token.
100
+ let publicSurface = 0;
101
+ for (const t of circleIr.types ?? []) {
102
+ publicSurface++;
103
+ for (const m of t.methods ?? []) {
104
+ if (!(m.modifiers ?? []).includes('private'))
105
+ publicSurface++;
106
+ }
107
+ }
108
+ return {
109
+ cbo: findMetric(metrics, 'CBO'),
110
+ rfc: findMetric(metrics, 'RFC'),
111
+ lcom: findMetric(metrics, 'LCOM'),
112
+ dit: findMetric(metrics, 'DIT'),
113
+ noc: findMetric(metrics, 'NOC'),
114
+ wmc: findMetric(metrics, 'WMC'),
115
+ imports,
116
+ publicSurface,
117
+ annotations,
118
+ };
119
+ }
120
+ // ───────────────────────────────────────────────────────────────────
121
+ // Distance functions
122
+ // ───────────────────────────────────────────────────────────────────
123
+ /**
124
+ * Per-axis max-normalize the CK + publicSurface scalars across the
125
+ * input set. Max-normalize (rather than z-score) is deliberate:
126
+ * cluster boundaries should be robust to outliers (one god-class
127
+ * with WMC=400 shouldn't squash everything else into the same
128
+ * cluster), and max-normalize gives a clean [0,1] domain that
129
+ * combines naturally with Jaccard.
130
+ *
131
+ * Returns the maxima so callers can normalize new vectors against
132
+ * the same scale (e.g. for the k-medoids loop on the same set).
133
+ */
134
+ export function computeNormalization(inputs) {
135
+ const max = (sel) => Math.max(1, ...inputs.map((i) => sel(i.vector)));
136
+ return {
137
+ cboMax: max((v) => v.cbo),
138
+ rfcMax: max((v) => v.rfc),
139
+ lcomMax: max((v) => v.lcom),
140
+ ditMax: max((v) => v.dit),
141
+ nocMax: max((v) => v.noc),
142
+ wmcMax: max((v) => v.wmc),
143
+ publicSurfaceMax: max((v) => v.publicSurface),
144
+ };
145
+ }
146
+ /** Euclidean distance on the max-normalized CK + publicSurface scalars. */
147
+ export function euclideanCkDistance(a, b, norm) {
148
+ const dx = [
149
+ (a.cbo - b.cbo) / norm.cboMax,
150
+ (a.rfc - b.rfc) / norm.rfcMax,
151
+ (a.lcom - b.lcom) / norm.lcomMax,
152
+ (a.dit - b.dit) / norm.ditMax,
153
+ (a.noc - b.noc) / norm.nocMax,
154
+ (a.wmc - b.wmc) / norm.wmcMax,
155
+ (a.publicSurface - b.publicSurface) / norm.publicSurfaceMax,
156
+ ];
157
+ return Math.sqrt(dx.reduce((acc, x) => acc + x * x, 0));
158
+ }
159
+ /**
160
+ * Jaccard distance on two string sets (1 - |intersection|/|union|).
161
+ * Returns 0 when both sets are empty (semantically: nothing
162
+ * distinguishes them on this axis).
163
+ */
164
+ export function jaccardDistance(a, b) {
165
+ if (a.length === 0 && b.length === 0)
166
+ return 0;
167
+ const sa = new Set(a);
168
+ const sb = new Set(b);
169
+ let intersect = 0;
170
+ for (const x of sa)
171
+ if (sb.has(x))
172
+ intersect++;
173
+ const union = sa.size + sb.size - intersect;
174
+ if (union === 0)
175
+ return 0;
176
+ return 1 - intersect / union;
177
+ }
178
+ /**
179
+ * Combined per-pair distance. Weights mirror the issue's signal
180
+ * vector ordering (CK is the primary signal; imports + annotations
181
+ * are tiebreakers). Total is in [0,1] so it composes cleanly with
182
+ * other metric-space algorithms.
183
+ *
184
+ * Weights chosen to sum to 1:
185
+ * 0.6 — CK + publicSurface Euclidean (clamped to 1 by sqrt of 7
186
+ * normalized components, since each (a-b)/max is in [-1,1])
187
+ * 0.2 — imports Jaccard
188
+ * 0.2 — annotations Jaccard
189
+ *
190
+ * Euclidean is divided by sqrt(7) to renormalize to [0,1] before
191
+ * weighting — without this, the CK term can exceed 1 and the total
192
+ * stops being a proper [0,1] distance.
193
+ */
194
+ export function mixedDistance(a, b, norm) {
195
+ const ck = euclideanCkDistance(a, b, norm) / Math.sqrt(7);
196
+ const importsD = jaccardDistance(a.imports, b.imports);
197
+ const annotationsD = jaccardDistance(a.annotations, b.annotations);
198
+ return 0.6 * ck + 0.2 * importsD + 0.2 * annotationsD;
199
+ }
200
+ // ───────────────────────────────────────────────────────────────────
201
+ // Seeded PRNG (deterministic across runs)
202
+ // ───────────────────────────────────────────────────────────────────
203
+ /**
204
+ * Mulberry32 — fast, well-distributed seeded PRNG. We don't need
205
+ * cryptographic randomness here, just reproducibility across runs
206
+ * for the same input set. Seed is derived from a digest of the
207
+ * sorted (path || vector) signatures so adding/removing files
208
+ * changes the seed but reordering inputs does not.
209
+ */
210
+ function mulberry32(seed) {
211
+ let s = seed >>> 0;
212
+ return () => {
213
+ s = (s + 0x6d2b79f5) >>> 0;
214
+ let t = s;
215
+ t = Math.imul(t ^ (t >>> 15), t | 1);
216
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
217
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
218
+ };
219
+ }
220
+ function deriveSeed(inputs) {
221
+ const sig = inputs
222
+ .map((i) => `${i.filePath}\0${i.vector.cbo},${i.vector.rfc},${i.vector.lcom},${i.vector.dit},${i.vector.noc},${i.vector.wmc},${i.vector.publicSurface}`)
223
+ .sort()
224
+ .join('|');
225
+ const hash = crypto.createHash('sha256').update(sig).digest();
226
+ return hash.readUInt32BE(0);
227
+ }
228
+ /**
229
+ * PAM-lite k-medoids on `inputs`.
230
+ *
231
+ * 1. Seed selection ("build" phase): pick the medoid that minimizes
232
+ * total distance to all points, then iteratively pick the point
233
+ * that maximizes minimum distance to existing medoids. This is
234
+ * deterministic given the seed and degenerates gracefully when
235
+ * `inputs.length <= k`.
236
+ * 2. Assignment: each point joins its closest medoid (lex path
237
+ * tiebreak on equal distances).
238
+ * 3. Swap phase: for each cluster, find the point that minimizes
239
+ * intra-cluster distance and promote it to medoid. Repeat until
240
+ * no medoid changes (or `maxIter` hits).
241
+ *
242
+ * Complexity is O(n^2 * iter) on the distance matrix. For the
243
+ * top-100 sweep (n ≈ 5000 per repo at the high end) this is ~25M
244
+ * pairwise computations per iteration — fine for the <2s budget
245
+ * in Acceptance #2 because distances are scalar arithmetic on
246
+ * a 9-dim vector.
247
+ */
248
+ export function clusterFiles(inputs, options) {
249
+ const k = Math.max(1, Math.min(options.k, inputs.length));
250
+ const maxIter = options.maxIter ?? 50;
251
+ if (inputs.length === 0) {
252
+ return { k: 0, assignments: [], medoids: [], iterations: 0 };
253
+ }
254
+ // Sort by path so ties are broken lexicographically regardless of
255
+ // input order.
256
+ const sorted = [...inputs].sort((a, b) => a.filePath.localeCompare(b.filePath));
257
+ const n = sorted.length;
258
+ const norm = computeNormalization(sorted);
259
+ // Precompute distance matrix (symmetric, diagonal zero).
260
+ const dist = Array.from({ length: n }, () => new Array(n).fill(0));
261
+ for (let i = 0; i < n; i++) {
262
+ for (let j = i + 1; j < n; j++) {
263
+ const d = mixedDistance(sorted[i].vector, sorted[j].vector, norm);
264
+ dist[i][j] = d;
265
+ dist[j][i] = d;
266
+ }
267
+ }
268
+ // Build phase — deterministic seed init.
269
+ const seed = options.seed ?? deriveSeed(sorted);
270
+ const rng = mulberry32(seed);
271
+ const medoidIdx = [];
272
+ // First medoid: minimize total distance to all points (1-medoid PAM).
273
+ let bestSum = Infinity;
274
+ let bestIdx = 0;
275
+ for (let i = 0; i < n; i++) {
276
+ let sum = 0;
277
+ for (let j = 0; j < n; j++)
278
+ sum += dist[i][j];
279
+ if (sum < bestSum || (sum === bestSum && sorted[i].filePath < sorted[bestIdx].filePath)) {
280
+ bestSum = sum;
281
+ bestIdx = i;
282
+ }
283
+ }
284
+ medoidIdx.push(bestIdx);
285
+ // Remaining medoids: maximize min-distance to existing medoids.
286
+ // RNG breaks ties on equally-far candidates for deterministic but
287
+ // non-degenerate seeding when the set has structural symmetry.
288
+ while (medoidIdx.length < k) {
289
+ let bestPick = -1;
290
+ let bestMinDist = -1;
291
+ const candidates = [];
292
+ for (let i = 0; i < n; i++) {
293
+ if (medoidIdx.includes(i))
294
+ continue;
295
+ let minD = Infinity;
296
+ for (const m of medoidIdx) {
297
+ if (dist[i][m] < minD)
298
+ minD = dist[i][m];
299
+ }
300
+ if (minD > bestMinDist) {
301
+ bestMinDist = minD;
302
+ bestPick = i;
303
+ candidates.length = 0;
304
+ candidates.push(i);
305
+ }
306
+ else if (minD === bestMinDist) {
307
+ candidates.push(i);
308
+ }
309
+ }
310
+ if (bestPick === -1)
311
+ break;
312
+ // Tiebreak: lex-first wins; if rng() < 0.5, allow a randomized
313
+ // alternative from the tied set. This preserves determinism
314
+ // (same seed → same pick) while avoiding pathological pile-ups
315
+ // on highly-symmetric inputs.
316
+ if (candidates.length > 1 && rng() < 0.5) {
317
+ candidates.sort((a, b) => sorted[a].filePath.localeCompare(sorted[b].filePath));
318
+ bestPick = candidates[Math.floor(rng() * candidates.length)];
319
+ }
320
+ else {
321
+ // Default: lex-first.
322
+ bestPick = candidates.reduce((best, c) => (sorted[c].filePath < sorted[best].filePath ? c : best), candidates[0]);
323
+ }
324
+ medoidIdx.push(bestPick);
325
+ }
326
+ // Iterate: assign → swap → check convergence.
327
+ let assignment = new Array(n).fill(0);
328
+ let iterations = 0;
329
+ for (let iter = 0; iter < maxIter; iter++) {
330
+ iterations = iter + 1;
331
+ // Assign each point to closest medoid (lex tiebreak via medoid order).
332
+ for (let i = 0; i < n; i++) {
333
+ let bestC = 0;
334
+ let bestD = dist[i][medoidIdx[0]];
335
+ for (let c = 1; c < medoidIdx.length; c++) {
336
+ const d = dist[i][medoidIdx[c]];
337
+ if (d < bestD ||
338
+ (d === bestD && sorted[medoidIdx[c]].filePath < sorted[medoidIdx[bestC]].filePath)) {
339
+ bestD = d;
340
+ bestC = c;
341
+ }
342
+ }
343
+ assignment[i] = bestC;
344
+ }
345
+ // Swap: for each cluster, find the point that minimizes
346
+ // intra-cluster distance and promote it.
347
+ const newMedoids = [...medoidIdx];
348
+ for (let c = 0; c < medoidIdx.length; c++) {
349
+ const members = [];
350
+ for (let i = 0; i < n; i++)
351
+ if (assignment[i] === c)
352
+ members.push(i);
353
+ if (members.length === 0)
354
+ continue;
355
+ let bestM = newMedoids[c];
356
+ let bestCost = Infinity;
357
+ for (const m of members) {
358
+ let cost = 0;
359
+ for (const other of members)
360
+ cost += dist[m][other];
361
+ if (cost < bestCost || (cost === bestCost && sorted[m].filePath < sorted[bestM].filePath)) {
362
+ bestCost = cost;
363
+ bestM = m;
364
+ }
365
+ }
366
+ newMedoids[c] = bestM;
367
+ }
368
+ // Check convergence (medoid set stable).
369
+ let changed = false;
370
+ for (let c = 0; c < newMedoids.length; c++) {
371
+ if (newMedoids[c] !== medoidIdx[c]) {
372
+ changed = true;
373
+ break;
374
+ }
375
+ }
376
+ medoidIdx.length = 0;
377
+ medoidIdx.push(...newMedoids);
378
+ if (!changed)
379
+ break;
380
+ }
381
+ const assignments = sorted.map((inp, i) => ({
382
+ filePath: inp.filePath,
383
+ clusterId: assignment[i],
384
+ }));
385
+ return {
386
+ k: medoidIdx.length,
387
+ assignments,
388
+ medoids: medoidIdx.map((i) => sorted[i].filePath),
389
+ iterations,
390
+ };
391
+ }
392
+ //# sourceMappingURL=cluster.js.map