axmap-cli 0.0.1
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.
- package/.claude/commands/ax-done.md +10 -0
- package/.claude/commands/ax-setup.md +31 -0
- package/.claude/commands/ax-start.md +15 -0
- package/.claude/commands/ax-tell.md +14 -0
- package/.claude/commands/ax-update.md +19 -0
- package/.claude/commands/ax.md +13 -0
- package/CLAUDE.md +309 -0
- package/LICENSE +20 -0
- package/README.md +207 -0
- package/app/README.md +366 -0
- package/app/eval/edges.mjs +242 -0
- package/app/lib/adjacent.mjs +125 -0
- package/app/lib/agentcli.mjs +153 -0
- package/app/lib/analyze.mjs +1159 -0
- package/app/lib/cochange.mjs +421 -0
- package/app/lib/datanodes.mjs +127 -0
- package/app/lib/entry.mjs +192 -0
- package/app/lib/featuregraph.mjs +389 -0
- package/app/lib/features.mjs +645 -0
- package/app/lib/fetchrepo-run.mjs +37 -0
- package/app/lib/fetchrepo.mjs +164 -0
- package/app/lib/flow.mjs +1089 -0
- package/app/lib/ladder.mjs +387 -0
- package/app/lib/langs.mjs +630 -0
- package/app/lib/live.mjs +346 -0
- package/app/lib/llm.mjs +594 -0
- package/app/lib/newfile.mjs +126 -0
- package/app/lib/prdiff.mjs +651 -0
- package/app/lib/reveal.mjs +316 -0
- package/app/lib/roots.mjs +186 -0
- package/app/lib/scope.mjs +342 -0
- package/app/lib/session.mjs +389 -0
- package/app/lib/slots.mjs +233 -0
- package/app/lib/ssot.mjs +277 -0
- package/app/lib/teamview.mjs +962 -0
- package/app/lib/terms.ko.mjs +169 -0
- package/app/server.mjs +1959 -0
- package/app/web/shell.css +538 -0
- package/app/web/shell.html +197 -0
- package/app/web/shell.js +638 -0
- package/app/web/stage.js +347 -0
- package/app/web/words.js +85 -0
- package/bin/axmap.mjs +1918 -0
- package/governance/GOVERNANCE.md +433 -0
- package/governance/gate.mjs +526 -0
- package/governance/vote.mjs +501 -0
- package/mcp/README.md +254 -0
- package/mcp/SETUP-FOR-AI.md +186 -0
- package/mcp/install.ps1 +341 -0
- package/mcp/install.sh +339 -0
- package/mcp/server.mjs +969 -0
- package/package.json +48 -0
- package/src/closure.mjs +343 -0
- package/src/governance.mjs +839 -0
- package/src/invariants.mjs +226 -0
- package/src/mrtarget.mjs +284 -0
- package/src/promote.mjs +177 -0
- package/src/protocol.mjs +423 -0
- package/src/repotarget.mjs +81 -0
- package/src/update.mjs +177 -0
- package/src/version.mjs +186 -0
- package/tools/bus.mjs +520 -0
- package/tools/cluster-experiment.mjs +256 -0
- package/tools/cluster-sweep.mjs +226 -0
- package/tools/make-icon.mjs +108 -0
- package/tools/mcp-register.mjs +269 -0
- package/tools/mr-target.mjs +49 -0
- package/tools/persona-bench.mjs +362 -0
- package/tools/pick-repo.mjs +229 -0
- package/tools/promote.mjs +550 -0
- package/tools/reveal-demo.mjs +158 -0
- package/tools/run-tests.mjs +42 -0
- package/tools/setup.mjs +490 -0
- package/tools/shortcut.mjs +121 -0
- package/tools/smoke.mjs +166 -0
- package/tools/topicgraph.py +154 -0
- package/tools/vendor.mjs +382 -0
- package/tools/version.mjs +115 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 실험 1 — 폴더 정답표와 미래 공변경 중 어느 쪽이 진짜인가.
|
|
4
|
+
*
|
|
5
|
+
* node tools/cluster-experiment.mjs --list repos.txt --out result.jsonl [--work /tmp/exp]
|
|
6
|
+
*
|
|
7
|
+
* 🔴 이 실험이 답해야 하는 질문은 하나다.
|
|
8
|
+
*
|
|
9
|
+
* **군집이 폴더보다 나은가?**
|
|
10
|
+
*
|
|
11
|
+
* D17 은 "폴더가 다른데 실제로 얽혀 있는 것을 찾는 것이 목적" 이라고 적었다.
|
|
12
|
+
* 그런데 axMap 자기 자신에서 재보니 미래적중 순위와 폴더일치 순위의 상관이
|
|
13
|
+
* 0.906 이었다 — 미래를 잘 맞히는 설정이 곧 폴더와 잘 맞는 설정이었다.
|
|
14
|
+
* 저장소 하나로는 "정리가 잘 된 저장소라서" 인지 "채점기가 폴더를 재고 있어서"
|
|
15
|
+
* 인지 구별할 수 없다. 그래서 여럿에 건다.
|
|
16
|
+
*
|
|
17
|
+
* ── 기준선 셋을 함께 낸다 ────────────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* 군집의 미래적중률만 내면 그 숫자가 높은지 낮은지 알 수가 없다. 비교 대상이
|
|
20
|
+
* 없으면 45% 가 훌륭한 건지 형편없는 건지 말할 수 없기 때문이다.
|
|
21
|
+
*
|
|
22
|
+
* dir1·dir2·dir3 디렉터리를 깊이별로 나눈 분할. **이걸 못 이기면 군집을
|
|
23
|
+
* 할 이유가 없다.** 깊이를 셋 다 내는 것은 폴더 쪽을
|
|
24
|
+
* 최대한 세우기 위해서다
|
|
25
|
+
* shuffled 블록 크기 분포는 그대로 두고 배정만 섞은 것
|
|
26
|
+
*
|
|
27
|
+
* 그리고 셋을 **lift = 적중률 ÷ 섞었을 때의 적중률** 로 환산해 비교한다.
|
|
28
|
+
* 적중률을 날것으로 비교하면 굵은 분할이 무조건 이기기 때문이다.
|
|
29
|
+
*
|
|
30
|
+
* 🔴 기준선을 약하게 잡으면 아무 의미가 없다. 폴더 쪽을 이길 수 있게 만들어
|
|
31
|
+
* 놓고 이겼다고 하는 것은 우리 자신을 속이는 것이다.
|
|
32
|
+
*/
|
|
33
|
+
import { execFileSync } from 'node:child_process'
|
|
34
|
+
import fs from 'node:fs'
|
|
35
|
+
import os from 'node:os'
|
|
36
|
+
import path from 'node:path'
|
|
37
|
+
|
|
38
|
+
import { build, scan } from '../app/lib/analyze.mjs'
|
|
39
|
+
import { coChange, readCommitSets } from '../app/lib/cochange.mjs'
|
|
40
|
+
import { buildGraph, cluster, rng } from '../corpus/cluster.mjs'
|
|
41
|
+
import { ari, containment, directoryPartition, shape } from '../corpus/clusterscore.mjs'
|
|
42
|
+
|
|
43
|
+
const argv = process.argv.slice(2)
|
|
44
|
+
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
|
|
45
|
+
|
|
46
|
+
const LIST = flag('list', null)
|
|
47
|
+
const OUT = flag('out', 'cluster-exp.jsonl')
|
|
48
|
+
const WORK = flag('work', path.join(os.tmpdir(), 'axmap-cluster-exp'))
|
|
49
|
+
const HOLDOUT = Number(flag('holdout', '0.2'))
|
|
50
|
+
const SEEDS = Number(flag('seeds', '3'))
|
|
51
|
+
const MAX_FILES = Number(flag('max-files', '3000'))
|
|
52
|
+
|
|
53
|
+
if (!LIST) { console.error('--list 이 필요하다 (한 줄에 owner/name 또는 git 주소)'); process.exit(1) }
|
|
54
|
+
|
|
55
|
+
// 격자는 좁게. 이 실험의 질문은 "어느 설정이 최고인가" 가 아니라
|
|
56
|
+
// "군집이 폴더를 이기는가" 이므로, 순위상관을 낼 만큼만 있으면 된다.
|
|
57
|
+
const GRID = {
|
|
58
|
+
algorithm: ['leiden', 'louvain'],
|
|
59
|
+
objective: ['modularity', 'cpm'],
|
|
60
|
+
resolution: [0.25, 0.5, 0.75, 1, 1.5, 2],
|
|
61
|
+
coWeight: [0, 1],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const say = (s) => process.stderr.write(`${new Date().toISOString().slice(11, 19)} ${s}\n`)
|
|
65
|
+
const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 28 })
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function rankOf(v) {
|
|
70
|
+
const idx = v.map((x, k) => [x, k]).sort((a, b) => a[0] - b[0])
|
|
71
|
+
const out = new Array(v.length)
|
|
72
|
+
for (let k = 0; k < idx.length;) {
|
|
73
|
+
let j = k
|
|
74
|
+
while (j + 1 < idx.length && idx[j + 1][0] === idx[k][0]) j++
|
|
75
|
+
const r = (k + j) / 2 + 1
|
|
76
|
+
for (let m = k; m <= j; m++) out[idx[m][1]] = r
|
|
77
|
+
k = j + 1
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function spearman(a, b) {
|
|
83
|
+
const p = []
|
|
84
|
+
for (let i = 0; i < a.length; i++) if (Number.isFinite(a[i]) && Number.isFinite(b[i])) p.push([a[i], b[i]])
|
|
85
|
+
if (p.length < 8) return null
|
|
86
|
+
const ra = rankOf(p.map((x) => x[0])), rb = rankOf(p.map((x) => x[1]))
|
|
87
|
+
const n = p.length, m = (n + 1) / 2
|
|
88
|
+
let num = 0, da = 0, db = 0
|
|
89
|
+
for (let k = 0; k < n; k++) { const x = ra[k] - m, y = rb[k] - m; num += x * y; da += x * x; db += y * y }
|
|
90
|
+
if (!da || !db) return null
|
|
91
|
+
return num / Math.sqrt(da * db)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 블록 크기 분포는 그대로 두고 배정만 섞는다.
|
|
96
|
+
*
|
|
97
|
+
* 🔴 이 기준선이 없으면 속는다. 블록이 크면 그 안에 떨어지는 쌍이 자동으로
|
|
98
|
+
* 많아진다 — 구조를 못 찾아도 적중률이 오른다. 크기 효과를 뺀 나머지가
|
|
99
|
+
* 진짜로 찾아낸 몫이다.
|
|
100
|
+
*/
|
|
101
|
+
function shuffledLike(membership, seed) {
|
|
102
|
+
const rand = rng(seed)
|
|
103
|
+
const out = Int32Array.from(membership)
|
|
104
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
105
|
+
const j = (rand() * (i + 1)) | 0
|
|
106
|
+
const t = out[i]; out[i] = out[j]; out[j] = t
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function measure(dir, name) {
|
|
114
|
+
const files = scan(dir, { maxFiles: MAX_FILES })
|
|
115
|
+
const graph = build(files)
|
|
116
|
+
const ids = graph.nodes.map((n) => n.id)
|
|
117
|
+
if (ids.length < 40) return { name, skip: `파일 ${ids.length}개` }
|
|
118
|
+
|
|
119
|
+
const raw = readCommitSets(dir)
|
|
120
|
+
if (!raw || raw.length < 80) return { name, skip: `커밋 ${raw?.length ?? 0}개` }
|
|
121
|
+
|
|
122
|
+
const cut = Math.max(15, Math.floor(raw.length * HOLDOUT))
|
|
123
|
+
const withCov = (a) => { a.coverage = raw.coverage; return a }
|
|
124
|
+
const futureSets = withCov(raw.slice(0, cut))
|
|
125
|
+
const pastSets = withCov(raw.slice(cut))
|
|
126
|
+
|
|
127
|
+
const nodeIds = new Set(ids)
|
|
128
|
+
const tag = (e) => e.map((x) => ({ ...x, origin: 'cochange' }))
|
|
129
|
+
const pastCo = tag(coChange(dir, nodeIds, { raw: pastSets, minSupport: 2 })?.edges ?? [])
|
|
130
|
+
const futureCo = tag(coChange(dir, nodeIds, { raw: futureSets, minSupport: 1 })?.edges ?? [])
|
|
131
|
+
if (futureCo.length < 30) return { name, skip: `미래 공변경 ${futureCo.length}쌍` }
|
|
132
|
+
|
|
133
|
+
// ── 기준선 ──────────────────────────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* 🔴 적중률을 그대로 비교하면 **무조건 굵은 분할이 이긴다.**
|
|
136
|
+
*
|
|
137
|
+
* 블록이 크면 그 안에 떨어지는 쌍이 자동으로 많아진다. 실제로 첫 실행에서
|
|
138
|
+
* "폴더 100%" 가 나왔는데, 그건 폴더 하나가 전부를 삼켜서 모든 쌍이
|
|
139
|
+
* 자동으로 안에 들어간 것이었다 — 아무것도 안 맞힌 것이 만점으로 보였다.
|
|
140
|
+
*
|
|
141
|
+
* 그래서 모든 분할에 대해 **같은 크기 분포로 섞은 대조군**을 함께 낸다.
|
|
142
|
+
*
|
|
143
|
+
* lift = 적중률 ÷ 섞었을 때의 적중률
|
|
144
|
+
*
|
|
145
|
+
* lift 1.0 은 "크기 때문에 맞은 것일 뿐 구조는 못 찾았다" 는 뜻이다.
|
|
146
|
+
* 이 값이라야 블록 5개짜리와 40개짜리를 같은 자로 잴 수 있다.
|
|
147
|
+
*/
|
|
148
|
+
const withBase = (m) => {
|
|
149
|
+
const hit = containment(ids, futureCo, m).weighted
|
|
150
|
+
const sh = containment(ids, futureCo, shuffledLike(m, 99)).weighted
|
|
151
|
+
return { ...shape(m), weighted: hit, shuffled: sh, lift: sh ? hit / sh : null }
|
|
152
|
+
}
|
|
153
|
+
const dirs = { 1: directoryPartition(ids, { depth: 1 }), 2: directoryPartition(ids, { depth: 2 }), 3: directoryPartition(ids, { depth: 3 }) }
|
|
154
|
+
const base = { dir1: withBase(dirs[1]), dir2: withBase(dirs[2]), dir3: withBase(dirs[3]) }
|
|
155
|
+
|
|
156
|
+
// ── 격자 ────────────────────────────────────────────────────────────────
|
|
157
|
+
const cands = []
|
|
158
|
+
for (const coWeight of GRID.coWeight) {
|
|
159
|
+
const g = buildGraph(ids, [...graph.edges, ...pastCo], { coWeight, minSupport: 2 })
|
|
160
|
+
for (const algorithm of GRID.algorithm) {
|
|
161
|
+
for (const objective of GRID.objective) {
|
|
162
|
+
for (const resolution of GRID.resolution) {
|
|
163
|
+
const runs = []
|
|
164
|
+
for (let s = 1; s <= SEEDS; s++) runs.push(cluster(algorithm, g, { resolution, objective, seed: s }).membership)
|
|
165
|
+
const m = runs[0]
|
|
166
|
+
const sh = shape(m)
|
|
167
|
+
let stab = 0, np = 0
|
|
168
|
+
for (let i = 0; i < runs.length; i++) for (let j = i + 1; j < runs.length; j++) {
|
|
169
|
+
const v = ari(runs[i], runs[j]); if (v != null) { stab += v; np++ }
|
|
170
|
+
}
|
|
171
|
+
cands.push({
|
|
172
|
+
label: `${algorithm}/${objective} γ=${resolution} co=${coWeight}`,
|
|
173
|
+
algorithm, objective, resolution, coWeight,
|
|
174
|
+
blocks: sh.usableBlocks, maxShare: sh.maxShare, singletonShare: sh.singletonShare,
|
|
175
|
+
stability: np ? stab / np : null,
|
|
176
|
+
ariDir1: ari(m, dirs[1]),
|
|
177
|
+
ariDir2: ari(m, dirs[2]),
|
|
178
|
+
heldOut: containment(ids, futureCo, m).weighted,
|
|
179
|
+
// 같은 크기 분포로 섞은 것. 구조가 아니라 크기로 맞는 몫.
|
|
180
|
+
heldOutShuffled: containment(ids, futureCo, shuffledLike(m, 99)).weighted,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const c of cands) c.lift = c.heldOutShuffled ? c.heldOut / c.heldOutShuffled : null
|
|
188
|
+
const usable = cands.filter((c) => c.heldOut != null)
|
|
189
|
+
const best = [...usable].sort((a, b) => b.heldOut - a.heldOut)[0] ?? null
|
|
190
|
+
// 얼룩(한 블록이 다 삼킨 것)을 뺀 뒤의 최고. 이쪽이 실제로 쓸 수 있는 답이다.
|
|
191
|
+
const bestSane = [...usable].filter((c) => c.maxShare <= 0.4 && c.blocks >= 3)
|
|
192
|
+
.sort((a, b) => b.heldOut - a.heldOut)[0] ?? null
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
name,
|
|
196
|
+
files: ids.length,
|
|
197
|
+
staticEdges: graph.edges.length,
|
|
198
|
+
commits: { past: pastSets.length, future: futureSets.length },
|
|
199
|
+
coEdges: { past: pastCo.length, future: futureCo.length },
|
|
200
|
+
parseCoverage: graph.nodes.filter((n) => n.parsed).length / ids.length,
|
|
201
|
+
base,
|
|
202
|
+
best,
|
|
203
|
+
bestSane,
|
|
204
|
+
// 🔴 실험의 핵심 숫자
|
|
205
|
+
rankCorr: {
|
|
206
|
+
heldOutVsDir1: spearman(usable.map((c) => c.heldOut), usable.map((c) => c.ariDir1)),
|
|
207
|
+
heldOutVsDir2: spearman(usable.map((c) => c.heldOut), usable.map((c) => c.ariDir2)),
|
|
208
|
+
heldOutVsStability: spearman(usable.map((c) => c.heldOut), usable.map((c) => c.stability)),
|
|
209
|
+
heldOutVsBlocks: spearman(usable.map((c) => c.heldOut), usable.map((c) => c.blocks)),
|
|
210
|
+
},
|
|
211
|
+
candidates: cands,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
const repos = fs.readFileSync(LIST, 'utf8').split('\n').map((s) => s.trim()).filter((s) => s && !s.startsWith('#'))
|
|
218
|
+
fs.mkdirSync(WORK, { recursive: true })
|
|
219
|
+
say(`저장소 ${repos.length}개 · 격자 ${GRID.algorithm.length * GRID.objective.length * GRID.resolution.length * GRID.coWeight.length}칸 · 씨앗 ${SEEDS}`)
|
|
220
|
+
|
|
221
|
+
let ok = 0, skipped = 0, failed = 0
|
|
222
|
+
for (const [i, entry] of repos.entries()) {
|
|
223
|
+
const url = entry.includes('://') || entry.startsWith('git@') ? entry : `https://github.com/${entry}.git`
|
|
224
|
+
const safe = entry.replace(/[^\w.-]/g, '_')
|
|
225
|
+
const dir = path.join(WORK, safe)
|
|
226
|
+
const t0 = Date.now()
|
|
227
|
+
try {
|
|
228
|
+
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
229
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
230
|
+
// 🔴 blob:none — 필요한 것은 커밋 이력이지 파일 내용의 모든 판본이 아니다.
|
|
231
|
+
// 다만 작업 트리는 필요하므로 얕은(shallow) 클론은 쓰지 않는다.
|
|
232
|
+
git(['clone', '-q', '--filter=blob:none', url, dir])
|
|
233
|
+
}
|
|
234
|
+
const r = measure(dir, entry)
|
|
235
|
+
r.at = new Date().toISOString()
|
|
236
|
+
r.ms = Date.now() - t0
|
|
237
|
+
fs.appendFileSync(OUT, `${JSON.stringify(r)}\n`)
|
|
238
|
+
if (r.skip) { skipped++; say(` [${i + 1}/${repos.length}] 건너뜀 ${entry} — ${r.skip}`) }
|
|
239
|
+
else {
|
|
240
|
+
ok++
|
|
241
|
+
const b = r.bestSane ?? r.best
|
|
242
|
+
const dirBest = Math.max(r.base.dir1.lift ?? 0, r.base.dir2.lift ?? 0, r.base.dir3.lift ?? 0)
|
|
243
|
+
say(` [${i + 1}/${repos.length}] ${entry} 파일 ${r.files} · lift 군집 ${b?.lift?.toFixed(2)}`
|
|
244
|
+
+ ` vs 폴더 ${dirBest.toFixed(2)}`
|
|
245
|
+
+ ` · 순위상관 ${r.rankCorr.heldOutVsDir1?.toFixed(2) ?? '—'} (${((Date.now() - t0) / 1000).toFixed(0)}초)`)
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
failed++
|
|
249
|
+
fs.appendFileSync(OUT, `${JSON.stringify({ name: entry, error: String(e.message).slice(0, 300), at: new Date().toISOString() })}\n`)
|
|
250
|
+
say(` [${i + 1}/${repos.length}] 실패 ${entry} — ${String(e.message).slice(0, 120)}`)
|
|
251
|
+
} finally {
|
|
252
|
+
// 재고 나서 즉시 지운다. 안 지우면 디스크가 먼저 죽는다 (docs/SSOT.md 와 같은 이유).
|
|
253
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
say(`끝. 성공 ${ok} · 건너뜀 ${skipped} · 실패 ${failed} → ${OUT}`)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 저장소 하나에서 군집 하이퍼파라미터를 쓸어본다 — 미래를 떼어놓고 채점한다.
|
|
4
|
+
*
|
|
5
|
+
* node tools/cluster-sweep.mjs <저장소경로> [--out out.json]
|
|
6
|
+
* node tools/cluster-sweep.mjs . --holdout 0.25 --seeds 5
|
|
7
|
+
*
|
|
8
|
+
* 🔴 이 도구의 전부는 **커밋 이력을 앞뒤로 자르는 한 줄**이다.
|
|
9
|
+
*
|
|
10
|
+
* 앞쪽(오래된 쪽) 커밋만으로 공변경을 만들어 블록을 나누고, 뒤쪽(최근) 커밋에서
|
|
11
|
+
* 실제로 같이 바뀐 쌍을 그 블록이 품고 있었는지 센다. 만든 데 쓴 엣지로 채점하면
|
|
12
|
+
* 무조건 잘 맞는다 — 그건 채점이 아니라 동어반복이다.
|
|
13
|
+
*
|
|
14
|
+
* docs/SSOT.md 가 "떼어놓고 검증(held-out)하지 않았다. 이것이 있어야 기준이
|
|
15
|
+
* 관찰에서 검증된 지침이 된다" 를 못 한 일로 적어 두었다. 여기가 그 고리다.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ 아직 코퍼스에 연결하지 않았다. 저장소 하나에서 이 값들이 말이 되는지
|
|
18
|
+
* 먼저 눈으로 보고 나서 `corpus/measure.mjs` 에 붙인다. 검증 안 된 계측을
|
|
19
|
+
* 16,876개에 먼저 거는 것은 순서가 거꾸로다.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
|
|
24
|
+
import { build, scan } from '../app/lib/analyze.mjs'
|
|
25
|
+
import { coChange, readCommitSets } from '../app/lib/cochange.mjs'
|
|
26
|
+
import { buildGraph, cluster } from '../corpus/cluster.mjs'
|
|
27
|
+
import {
|
|
28
|
+
agreement, ari, containment, directoryPartition, rank, shape,
|
|
29
|
+
} from '../corpus/clusterscore.mjs'
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// 인자
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2)
|
|
36
|
+
const flag = (name, def) => {
|
|
37
|
+
const i = argv.indexOf(`--${name}`)
|
|
38
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : def
|
|
39
|
+
}
|
|
40
|
+
const root = argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1]?.startsWith('--') !== true)
|
|
41
|
+
?? argv[0]
|
|
42
|
+
|
|
43
|
+
if (!root || root.startsWith('--')) {
|
|
44
|
+
console.error('사용법: node tools/cluster-sweep.mjs <저장소경로> [--out out.json] [--holdout 0.2] [--seeds 4]')
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const HOLDOUT = Number(flag('holdout', '0.2'))
|
|
49
|
+
const SEEDS = Number(flag('seeds', '4'))
|
|
50
|
+
const OUT = flag('out', null)
|
|
51
|
+
const MAX_FILES = Number(flag('max-files', '4000'))
|
|
52
|
+
|
|
53
|
+
// 쓸어볼 격자. 넓이가 먼저다 — 좁게 깊이 파면 다른 조합이 더 나았는지 영영 모른다.
|
|
54
|
+
const GRID = {
|
|
55
|
+
algorithm: (flag('algos', 'leiden,louvain,labelprop')).split(','),
|
|
56
|
+
objective: (flag('objectives', 'modularity,cpm')).split(','),
|
|
57
|
+
resolution: (flag('gammas', '0.25,0.5,0.75,1,1.5,2,3')).split(',').map(Number),
|
|
58
|
+
coWeight: (flag('coweights', '0,0.5,1,2')).split(',').map(Number),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// 재료
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const t0 = Date.now()
|
|
66
|
+
const say = (s) => process.stderr.write(`${s}\n`)
|
|
67
|
+
|
|
68
|
+
say(`저장소 ${path.resolve(root)}`)
|
|
69
|
+
const files = scan(root, { maxFiles: MAX_FILES })
|
|
70
|
+
const graph = build(files)
|
|
71
|
+
const ids = graph.nodes.map((n) => n.id)
|
|
72
|
+
const nodeIds = new Set(ids)
|
|
73
|
+
say(`파일 ${ids.length}개 · 정적 엣지 ${graph.edges.length}개`)
|
|
74
|
+
|
|
75
|
+
if (ids.length < 20) {
|
|
76
|
+
say('파일이 20개도 안 된다 — 군집을 말할 그래프가 아니다.')
|
|
77
|
+
process.exit(1)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 커밋을 앞뒤로 자른다.
|
|
82
|
+
*
|
|
83
|
+
* `readCommitSets` 는 **최신이 먼저**다 (git log 순서). 그래서 앞쪽 slice 가
|
|
84
|
+
* 미래고 뒤쪽 slice 가 과거다 — 직관과 반대이므로 이름을 분명히 붙인다.
|
|
85
|
+
*/
|
|
86
|
+
const raw = readCommitSets(root)
|
|
87
|
+
if (!raw || raw.length < 50) {
|
|
88
|
+
say(`커밋이 ${raw?.length ?? 0}개뿐이다 — 떼어놓을 미래가 없다.`)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
const cut = Math.max(10, Math.floor(raw.length * HOLDOUT))
|
|
92
|
+
const withCoverage = (arr) => { arr.coverage = raw.coverage; return arr }
|
|
93
|
+
const futureSets = withCoverage(raw.slice(0, cut))
|
|
94
|
+
const pastSets = withCoverage(raw.slice(cut))
|
|
95
|
+
say(`커밋 ${raw.length}개 → 과거 ${pastSets.length} / 미래 ${futureSets.length} (holdout ${HOLDOUT})`)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 🔴 origin 을 여기서 붙인다. `coChange().edges` 에는 없다 — 붙이는 것은
|
|
99
|
+
* `overlayEdges` 인데 우리는 그걸 안 거치기 때문이다. 안 붙이면
|
|
100
|
+
* `buildGraph` 가 거부한다 (전에는 조용히 정적 엣지로 삼켰다).
|
|
101
|
+
*/
|
|
102
|
+
const tag = (edges) => edges.map((e) => ({ ...e, origin: 'cochange' }))
|
|
103
|
+
|
|
104
|
+
const pastCo = tag(coChange(root, nodeIds, { raw: pastSets, minSupport: 2 })?.edges ?? [])
|
|
105
|
+
const futureCo = tag(coChange(root, nodeIds, { raw: futureSets, minSupport: 1 })?.edges ?? [])
|
|
106
|
+
say(`공변경 엣지 — 과거 ${pastCo.length} · 미래(채점용) ${futureCo.length}`)
|
|
107
|
+
|
|
108
|
+
if (futureCo.length < 20) {
|
|
109
|
+
say('미래 공변경이 20쌍도 안 된다 — 채점할 수 없다. holdout 을 늘려보라.')
|
|
110
|
+
process.exit(1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const dirTruth = directoryPartition(ids, { depth: 1 })
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// 쓸기
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const candidates = []
|
|
120
|
+
let done = 0
|
|
121
|
+
const total = GRID.algorithm.length * GRID.objective.length * GRID.resolution.length * GRID.coWeight.length
|
|
122
|
+
|
|
123
|
+
for (const coWeight of GRID.coWeight) {
|
|
124
|
+
// 공변경 무게가 바뀌면 그래프 자체가 바뀐다. 격자 바깥에서 한 번만 만든다.
|
|
125
|
+
const g = buildGraph(ids, [...graph.edges, ...pastCo], { coWeight, minSupport: 2 })
|
|
126
|
+
|
|
127
|
+
for (const algorithm of GRID.algorithm) {
|
|
128
|
+
for (const objective of GRID.objective) {
|
|
129
|
+
for (const resolution of GRID.resolution) {
|
|
130
|
+
// label propagation 은 파라미터가 없다. 격자를 돌면 같은 답을 수십 번 낸다.
|
|
131
|
+
if (algorithm === 'labelprop' && (objective !== GRID.objective[0] || resolution !== GRID.resolution[0])) {
|
|
132
|
+
done++
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const runs = []
|
|
137
|
+
for (let s = 1; s <= SEEDS; s++) {
|
|
138
|
+
runs.push(cluster(algorithm, g, { resolution, objective, seed: s }).membership)
|
|
139
|
+
}
|
|
140
|
+
const m = runs[0]
|
|
141
|
+
const sh = shape(m)
|
|
142
|
+
|
|
143
|
+
candidates.push({
|
|
144
|
+
label: `${algorithm}/${objective} γ=${resolution} co=${coWeight}`,
|
|
145
|
+
algorithm,
|
|
146
|
+
objective,
|
|
147
|
+
resolution,
|
|
148
|
+
coWeight,
|
|
149
|
+
shape: sh,
|
|
150
|
+
stability: agreement(runs),
|
|
151
|
+
dirAgreement: ari(m, dirTruth),
|
|
152
|
+
// 🔴 만든 데 안 쓴 엣지로만 잰다
|
|
153
|
+
heldOut: containment(ids, futureCo, m),
|
|
154
|
+
// 대조군: 만든 데 쓴 엣지로 재면 얼마나 나오나. 둘의 차이가 곧 과적합이다.
|
|
155
|
+
inSample: containment(ids, pastCo, m),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
if (++done % 10 === 0) say(` ${done}/${total}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = rank(candidates)
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 내기
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const pct = (v) => (v == null ? ' — ' : `${(v * 100).toFixed(1)}%`.padStart(6))
|
|
171
|
+
const num = (v) => (v == null ? ' — ' : v.toFixed(2))
|
|
172
|
+
|
|
173
|
+
console.log(`\n저장소 ${path.resolve(root)}`)
|
|
174
|
+
console.log(`파일 ${ids.length} · 정적엣지 ${graph.edges.length} · 과거커밋 ${pastSets.length} · 미래커밋 ${futureSets.length}`)
|
|
175
|
+
console.log(`후보 ${candidates.length}개 중 제약 통과 ${result.passed.length}개\n`)
|
|
176
|
+
|
|
177
|
+
console.log('상위 12 (제약 통과분, 미래 공변경 적중률 순)')
|
|
178
|
+
console.log(' ' + '조합'.padEnd(34) + '블록'.padEnd(6) + '최대'.padEnd(7) + '안정'.padEnd(7)
|
|
179
|
+
+ '폴더'.padEnd(7) + '미래'.padEnd(8) + '과거')
|
|
180
|
+
for (const c of result.passed.slice(0, 12)) {
|
|
181
|
+
console.log(' ' + c.label.padEnd(34)
|
|
182
|
+
+ String(c.shape.usableBlocks).padEnd(6)
|
|
183
|
+
+ pct(c.shape.maxShare).padEnd(7)
|
|
184
|
+
+ num(c.stability.mean).padEnd(7)
|
|
185
|
+
+ num(c.dirAgreement).padEnd(7)
|
|
186
|
+
+ pct(c.heldOut.weighted).padEnd(8)
|
|
187
|
+
+ pct(c.inSample.weighted))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!result.best) {
|
|
191
|
+
console.log(`\n고르지 못했다: ${result.why}`)
|
|
192
|
+
// 왜 다 떨어졌는지 사유를 세어 보여준다. 빈 답만 주면 다음 사람이 격자를 헤맨다.
|
|
193
|
+
const tally = new Map()
|
|
194
|
+
for (const c of result.judged) for (const f of c.fail) {
|
|
195
|
+
const kind = f.replace(/[\d.]+/g, 'N')
|
|
196
|
+
tally.set(kind, (tally.get(kind) ?? 0) + 1)
|
|
197
|
+
}
|
|
198
|
+
console.log('\n탈락 사유')
|
|
199
|
+
for (const [why, n] of [...tally].sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(4)}회 ${why}`)
|
|
200
|
+
} else {
|
|
201
|
+
const b = result.best
|
|
202
|
+
console.log(`\n고른 것 ${b.label}`)
|
|
203
|
+
console.log(` 블록 ${b.shape.usableBlocks}개 · 최대 블록 ${pct(b.shape.maxShare)} · 혼자 ${pct(b.shape.singletonShare)}`)
|
|
204
|
+
console.log(` 안정성 평균 ${num(b.stability.mean)} / 최악 ${num(b.stability.min)}`)
|
|
205
|
+
console.log(` 폴더와의 일치 ${num(b.dirAgreement)} (낮을수록 폴더가 모르는 것을 찾았다는 뜻)`)
|
|
206
|
+
console.log(` 미래 공변경 적중 ${pct(b.heldOut.weighted)} · 같은 것을 과거로 재면 ${pct(b.inSample.weighted)}`)
|
|
207
|
+
const gap = (b.inSample.weighted ?? 0) - (b.heldOut.weighted ?? 0)
|
|
208
|
+
console.log(` 과적합 폭 ${pct(gap)} (클수록 과거에만 맞는 분할이다)`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (OUT) {
|
|
212
|
+
fs.writeFileSync(OUT, JSON.stringify({
|
|
213
|
+
root: path.resolve(root),
|
|
214
|
+
at: new Date().toISOString(),
|
|
215
|
+
files: ids.length,
|
|
216
|
+
staticEdges: graph.edges.length,
|
|
217
|
+
commits: { past: pastSets.length, future: futureSets.length, holdout: HOLDOUT },
|
|
218
|
+
seeds: SEEDS,
|
|
219
|
+
grid: GRID,
|
|
220
|
+
best: result.best?.label ?? null,
|
|
221
|
+
why: result.why,
|
|
222
|
+
candidates: result.judged,
|
|
223
|
+
}, null, 1))
|
|
224
|
+
say(`\n적었다: ${OUT}`)
|
|
225
|
+
}
|
|
226
|
+
say(`${((Date.now() - t0) / 1000).toFixed(1)}초`)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 앱 아이콘(`desktop/build/icon.ico`)을 코드로 그린다.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 왜 그림 파일을 안 싣는가.
|
|
6
|
+
*
|
|
7
|
+
* 저장소에 바이너리를 넣지 않으면 리뷰에서 내용이 보이고, 크기나 색을 고치는 데
|
|
8
|
+
* 이미지 편집기가 필요 없다. 모양도 이 앱의 은유 그대로다 — 가운데 시작점과
|
|
9
|
+
* 그것을 두르는 궤도(`graph.js` 의 `SHELL_GAP` 이 그리는 고리).
|
|
10
|
+
* 트레이 아이콘(`desktop/main.mjs` 의 `trayIcon`)과 같은 도형이다.
|
|
11
|
+
*
|
|
12
|
+
* 🔴 ICO 포맷에서 두 번 밟는 곳.
|
|
13
|
+
*
|
|
14
|
+
* ① 픽셀은 **BGRA** 이고 행은 **아래에서 위로** 쌓인다. RGBA 로 채우면 빨강과
|
|
15
|
+
* 파랑이 바뀌고, 위에서 아래로 쌓으면 상하가 뒤집힌 채 조용히 뜬다
|
|
16
|
+
* ② 헤더의 `biHeight` 는 **높이의 두 배**다. XOR 비트맵과 AND 마스크를 함께
|
|
17
|
+
* 담기 때문이다. 32비트라 마스크는 안 쓰지만 **자리는 반드시 있어야** 한다
|
|
18
|
+
*
|
|
19
|
+
* $ node tools/make-icon.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
const OUT = path.join(ROOT, 'desktop', 'build', 'icon.ico')
|
|
28
|
+
|
|
29
|
+
/** 여러 크기를 담는다. 작업 표시줄은 작은 것을, 바탕화면은 큰 것을 고른다. */
|
|
30
|
+
const SIZES = [16, 32, 48, 64, 128, 256]
|
|
31
|
+
|
|
32
|
+
/** shell.css 의 `--accent` 와 같은 값. 한쪽만 고치면 아이콘과 화면이 어긋난다. */
|
|
33
|
+
const INK = [217, 119, 87]
|
|
34
|
+
|
|
35
|
+
/** 반지름 `at` 에 두께 `half` 로 부드러운 획을 놓는다. */
|
|
36
|
+
const band = (d, at, half) => Math.max(0, Math.min(1, 1 - Math.abs(d - at) / half))
|
|
37
|
+
|
|
38
|
+
/** 한 크기의 XOR 비트맵(BGRA · 아래에서 위로). */
|
|
39
|
+
function pixels(s) {
|
|
40
|
+
const buf = Buffer.alloc(s * s * 4)
|
|
41
|
+
const c = (s - 1) / 2
|
|
42
|
+
const ring = s * 0.36
|
|
43
|
+
const stroke = Math.max(1, s * 0.055)
|
|
44
|
+
const dot = Math.max(0.8, s * 0.055)
|
|
45
|
+
|
|
46
|
+
for (let y = 0; y < s; y++) {
|
|
47
|
+
for (let x = 0; x < s; x++) {
|
|
48
|
+
const d = Math.hypot(x - c, y - c)
|
|
49
|
+
const a = Math.min(1, band(d, ring, stroke) + band(d, 0, dot))
|
|
50
|
+
if (a <= 0.004) continue
|
|
51
|
+
// 아래에서 위로 쌓는다 — 안 그러면 상하가 뒤집힌 채 조용히 뜬다
|
|
52
|
+
const i = ((s - 1 - y) * s + x) * 4
|
|
53
|
+
buf[i] = INK[2] // B
|
|
54
|
+
buf[i + 1] = INK[1] // G
|
|
55
|
+
buf[i + 2] = INK[0] // R
|
|
56
|
+
buf[i + 3] = Math.round(255 * a)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return buf
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** BITMAPINFOHEADER + XOR + AND 마스크. */
|
|
63
|
+
function image(s) {
|
|
64
|
+
const head = Buffer.alloc(40)
|
|
65
|
+
head.writeUInt32LE(40, 0) // biSize
|
|
66
|
+
head.writeInt32LE(s, 4) // biWidth
|
|
67
|
+
head.writeInt32LE(s * 2, 8) // 🔴 biHeight — XOR 과 AND 를 합친 높이다
|
|
68
|
+
head.writeUInt16LE(1, 12) // biPlanes
|
|
69
|
+
head.writeUInt16LE(32, 14) // biBitCount
|
|
70
|
+
head.writeUInt32LE(0, 16) // biCompression = BI_RGB
|
|
71
|
+
|
|
72
|
+
// 32비트는 알파를 쓰므로 마스크는 0(불투명)으로 두되 자리는 있어야 한다.
|
|
73
|
+
const maskRow = Math.ceil(s / 32) * 4
|
|
74
|
+
const mask = Buffer.alloc(maskRow * s)
|
|
75
|
+
|
|
76
|
+
return Buffer.concat([head, pixels(s), mask])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ico(sizes) {
|
|
80
|
+
const imgs = sizes.map(image)
|
|
81
|
+
const dir = Buffer.alloc(6)
|
|
82
|
+
dir.writeUInt16LE(0, 0) // reserved
|
|
83
|
+
dir.writeUInt16LE(1, 2) // type = icon
|
|
84
|
+
dir.writeUInt16LE(sizes.length, 4)
|
|
85
|
+
|
|
86
|
+
let offset = 6 + sizes.length * 16
|
|
87
|
+
const entries = sizes.map((s, i) => {
|
|
88
|
+
const e = Buffer.alloc(16)
|
|
89
|
+
// 256 은 0 으로 적는다 — 한 바이트에 안 들어간다
|
|
90
|
+
e.writeUInt8(s >= 256 ? 0 : s, 0)
|
|
91
|
+
e.writeUInt8(s >= 256 ? 0 : s, 1)
|
|
92
|
+
e.writeUInt8(0, 2) // 팔레트 없음
|
|
93
|
+
e.writeUInt8(0, 3)
|
|
94
|
+
e.writeUInt16LE(1, 4) // planes
|
|
95
|
+
e.writeUInt16LE(32, 6) // bitCount
|
|
96
|
+
e.writeUInt32LE(imgs[i].length, 8)
|
|
97
|
+
e.writeUInt32LE(offset, 12)
|
|
98
|
+
offset += imgs[i].length
|
|
99
|
+
return e
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return Buffer.concat([dir, ...entries, ...imgs])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fs.mkdirSync(path.dirname(OUT), { recursive: true })
|
|
106
|
+
const buf = ico(SIZES)
|
|
107
|
+
fs.writeFileSync(OUT, buf)
|
|
108
|
+
console.log(`아이콘 ${path.relative(ROOT, OUT)} · ${SIZES.join('·')}px · ${buf.length.toLocaleString()} bytes`)
|