dsh-goal-ledger 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -41,7 +41,7 @@ embedding 距离衡量的是「目标与输出的字面语义分离度」,有
41
41
  DSH 的插件市场读取你**自己配置的索引地址**。本插件已发布到 npm,索引随包提供,在 设置 → 插件 → 发现(Discover)里添加:
42
42
 
43
43
  ```
44
- https://unpkg.com/dsh-goal-ledger@0.2.2/market-index.json
44
+ https://unpkg.com/dsh-goal-ledger@0.3.0/market-index.json
45
45
  ```
46
46
 
47
47
  然后在市场里搜索「目标台账」或 `dsh-goal-ledger` 安装即可。
@@ -78,7 +78,7 @@ dsh plugin --profile desktop add file:C:/Project/dsh-goal-ledger
78
78
  1. 打开 设置 → 目标台账
79
79
  2. 点「开始分析」(首次扫描约几十秒;嵌入打分需要 embedding 凭据)
80
80
  3. 切换六个标签页查看:
81
- - **漂移场**:光点图,半径 = 离目标多远(见下)
81
+ - **漂移场**:光点图,一个光点一个目标,半径 = 平均离目标多远(见下)
82
82
  - **失败聚类**:按次数排序,点行展开原始错误与出现位置
83
83
  - **目标漂移**:按平均距离排序,标注判定档位
84
84
  - **漂移轮次**:单轮最漂移的 10 条
@@ -116,17 +116,23 @@ dsh plugin --profile desktop add file:C:/Project/dsh-goal-ledger
116
116
 
117
117
  ## 漂移场怎么看
118
118
 
119
- 灰蓝底、金色光点,Canvas 2D 实时绘制(无图表库,无动画依赖):
120
-
121
- - **中心亮点** = 「最初的目标」。它是标尺的原点,不是一个会话。
122
- - **每个光点** = 一个目标。**半径就是距离** —— 越靠外,整段会话偏离最初措辞越远。
123
- - **虚线圈** = 档位阈值 0.35 / 0.50 / 0.65,和表格里的标签是同一套数字,图和数不会互相矛盾。
124
- - **颜色**:灰蓝 = 聚焦,浅金 = 有偏离,橙金 = 偏题,亮金 = 严重漂移。颜色只强化档位,不额外编码信息。
125
- - **光点大小** = 该目标经历的轮次数(取对数,防止单个长会话压掉其他点)。
126
- - **角度没有含义** —— 黄金角螺旋 + 按 goalId 哈希的抖动,只为了不让点叠在一起,并保证同一目标每次渲染位置一致。
127
- - **悬停**看目标原文、轮次、调用数、失败数。
128
-
129
- 纯装饰元素(光尘、核心光晕、轨道弧、光点周围的喷溅)都在 `drawField()` 里,和几何布局分开:改观感不用碰布局,改布局不用碰观感。
119
+ Canvas 2D 实时绘制(无图表库,无动画依赖):
120
+
121
+ - **中心亮点** = 「最初的目标」,是标尺原点,不是一个会话。
122
+ - **一个光点 = 一个目标**(整段会话的那个目标),**不是一轮对话**。轮次级明细去「漂移轮次」Tab 看。
123
+ - **半径** = 该目标整段会话里**可计分轮次的平均距离**(`meanDistance`)。明确一句:不是最终值、不是最大值;纯工具调用轮次(无文字输出)不参与平均。
124
+ - **虚线圈** = 档位阈值 0.35 / 0.50 / 0.65,数字和档位名(有偏离 / 偏题 / 严重)**直接标在环上**,和表格里的标签是同一套数字。环标签的方位不是写死的,也**不是三环共用**:每个环独立在自己的候选方位里挑代价最小的,并同时避让已放好的环标签 + 严重档的数值标签 + 光点本身 + 12 点接缝。两件事值得记下来:
125
+ - 31 个目标的真实数据下,**59 个候选方位没有一个能完全不碰光点**(最低代价 302)。环面就那么大,光点铺满之后「完全不重叠」不存在,所以这是择优而不是求解。
126
+ - 三环共用同一个方位会让它们挤在一根射线上(实测出现过 99° 和 102°,只差 3°,框不相交但看着是一坨)。所以加了一条**方位间隔下限**(`MIN_CHIP_SEP`,约 22°),低于就重罚,把三个环推向不同方向。
127
+ - **颜色四档**,四种可区分色相,不再有两个几乎同色的档位:纯蓝 = 聚焦,青绿 = 有偏离,橙 = 偏题,亮金 = 严重漂移。颜色只强化档位,不额外编码信息。
128
+ - **角度** = 会话开始时间,**顺时针**推进(旧 → 新)。注意是**按开始时间的次序等角分布**,不是按时间间隔比例:真实数据里开始时间跨一周、最小间隔 10ms,按比例映射会把几十个点压在同一根射线上。角度编码**先后顺序**,具体时间点看悬停。12 点方向留了一道**接缝**(虚线楔形),是「最新的点回绕到最旧的点」的地方,图上用 旧 / 新 两个标号标出来了。
129
+ - **光点之间的连线 = 目标与目标之间的相似度**:1 − cos(目标A向量, 目标B向量)。细线 = 同一主题(距离 ≤ 0.42);亮线 = 几乎同一个目标被重复提问(距离 ≤ 0.18),这种情况上一次的路径通常可以直接复用。这两档边的选取规则在 `src/distance.js` 的 `buildGoalLinks()`(每个目标最多连 3 条最近的边)。连线用的向量就是打分时已经算好的目标向量,不额外调用 embedding。
130
+ - **光点与中心之间不再有装饰性螺旋线**。之前那层曲线不编码任何数据,容易被误读成「对话轮次轨迹」,所以移除了;要看真实轮次轨迹请用「漂移轮次」Tab。
131
+ - **光点大小** = 该目标的轮次数(取对数,防止单个长会话压掉其他点)。
132
+ - **数值标签**:只有严重档(亮金)常驻标数值,其余光点悬停才显示,保证全图规则统一。
133
+ - **悬停** = 目标原文、轮次、调用数、失败数。
134
+
135
+ 纯装饰元素(光尘、核心光晕、核心周围的轨道弧、光点周围的喷溅)与语义层(半径 / 角度 / 连线 / 环标签)在代码里是分开的:装饰集中在 `drawField()`,几何布局在 `layoutPoints()`,改观感不碰布局。**连线与环标签属于语义层,不是装饰**。
130
136
 
131
137
  ## Embedding 配置
132
138
 
package/lib/client.js CHANGED
@@ -79,10 +79,10 @@ window.__ModuleLoader__.load({
79
79
  .gl-table tr:hover td { background:rgba(128,128,128,.06); }
80
80
  .gl-num { font-variant-numeric:tabular-nums; white-space:nowrap; }
81
81
  .gl-tag { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; white-space:nowrap; }
82
- .gl-tag-red { background:rgba(255,201,77,.20); color:#a8710a; }
83
- .gl-tag-orange { background:rgba(216,168,96,.20); color:#8f6224; }
84
- .gl-tag-yellow { background:rgba(148,174,192,.24); color:#41677f; }
85
- .gl-tag-green { background:rgba(125,156,179,.22); color:#3a5c73; }
82
+ .gl-tag-red { background:rgba(255,210,74,.24); color:#8a6800; }
83
+ .gl-tag-orange { background:rgba(232,148,74,.20); color:#96551c; }
84
+ .gl-tag-yellow { background:rgba(53,214,176,.20); color:#1d7f6a; }
85
+ .gl-tag-green { background:rgba(77,159,254,.18); color:#2f6fb8; }
86
86
  .gl-tag-grey { background:rgba(128,128,128,.14); opacity:.85; }
87
87
  .gl-obj { max-width:420px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
88
88
  .gl-mono { font-family:ui-monospace, Consolas, monospace; font-size:11px; }
@@ -98,13 +98,18 @@ window.__ModuleLoader__.load({
98
98
  /* --- drift field ------------------------------------------------------- */
99
99
  .gl-field-wrap { position:relative; border-radius:12px; overflow:hidden;
100
100
  border:1px solid rgba(120,150,180,.28); background:#0b1219; }
101
- .gl-field-canvas { display:block; width:100%; height:340px; cursor:crosshair; }
101
+ .gl-field-canvas { display:block; width:100%; height:400px; cursor:crosshair; }
102
102
  .gl-field-legend { position:absolute; left:12px; bottom:10px; display:flex; gap:12px;
103
- flex-wrap:wrap; font-size:11px; color:rgba(198,214,228,.82);
103
+ flex-wrap:wrap; align-items:center; font-size:11px; color:rgba(198,214,228,.82);
104
104
  background:rgba(8,14,20,.55); padding:5px 9px; border-radius:6px;
105
105
  backdrop-filter:blur(3px); pointer-events:none; }
106
106
  .gl-legend-dot { display:inline-block; width:7px; height:7px; border-radius:50%;
107
107
  margin-right:5px; vertical-align:middle; }
108
+ .gl-legend-sep { opacity:.35; }
109
+ .gl-legend-line { display:inline-block; width:14px; height:0; margin-right:5px;
110
+ vertical-align:middle; border-top:1px solid; }
111
+ .gl-legend-line-weak { border-color:rgba(140,186,220,.75); }
112
+ .gl-legend-line-strong { border-color:rgba(255,214,140,.95); }
108
113
  .gl-field-tip { position:absolute; pointer-events:none; z-index:3; max-width:320px;
109
114
  background:rgba(10,17,24,.94); border:1px solid rgba(140,180,215,.35);
110
115
  border-radius:8px; padding:8px 10px; font-size:11.5px; line-height:1.5;
@@ -153,33 +158,55 @@ window.__ModuleLoader__.load({
153
158
  }
154
159
 
155
160
  // ---------------------------------------------------------------------
156
- // Drift field: cold gray-blue canvas, one gold light per goal.
157
- // The radius IS the drift — the bright core is the objective as asked,
158
- // the rim is maximal distance from it. Angle is decorative (golden-angle
159
- // spacing + a hash-derived jitter, so a goal keeps its spot between
160
- // renders). Colors match the band tags used in the tables.
161
+ // Drift field: dark starfield, one light per goal.
162
+ //
163
+ // Every channel carries data, and each one answers a different question:
164
+ // radius — how far this goal's session drifted from the goal as asked
165
+ // (mean cosine distance over its scorable turns)
166
+ // angle — WHEN the session started, clockwise from 12 o'clock, old→new
167
+ // colour — the distance band, same numbers as the tables
168
+ // size — turn count (log), so one long session cannot dwarf the rest
169
+ // edges — similarity BETWEEN goals: same topic (faint) or the same
170
+ // request asked twice (bright, usually a reusable path)
171
+ //
172
+ // The edges are what makes this a network instead of a star: goal-to-goal
173
+ // similarity is already computed for scoring, so the links cost no extra
174
+ // embedding call. Decoration (dust, core bloom, orbital arcs, point spray)
175
+ // is confined to drawField() and never encodes anything.
161
176
  // ---------------------------------------------------------------------
162
177
 
163
- // Cold gray-blue field, gold light. Gold means "drifted", gray-blue means
164
- // "focused" — the color carries the same meaning as the table tags instead
165
- // of being decoration, and the palette stays within the gray-blue brief.
178
+ // Four bands, four distinguishable hues — the first two used to be two
179
+ // near-identical gray-blues on a near-black field, which read as three
180
+ // bands. Blue = still on target, teal = slightly off, orange = off topic,
181
+ // bright gold = badly drifted. Same palette drives the table tags.
166
182
  const BAND_COLORS = {
167
- green: '#7d9cb3',
168
- yellow: '#94aec0',
169
- orange: '#d8a860',
170
- red: '#ffc94d',
171
- grey: '#75899b'
183
+ green: '#4d9ffe',
184
+ yellow: '#35d6b0',
185
+ orange: '#e8944a',
186
+ red: '#ffd24a',
187
+ grey: '#8fa7bb'
172
188
  }
173
189
  const BAND_GLOW = {
174
- green: [125, 156, 179],
175
- yellow: [148, 174, 192],
176
- orange: [216, 168, 96],
177
- red: [255, 201, 77],
178
- grey: [117, 137, 155]
190
+ green: [77, 159, 254],
191
+ yellow: [53, 214, 176],
192
+ orange: [232, 148, 74],
193
+ red: [255, 210, 74],
194
+ grey: [143, 167, 187]
179
195
  }
180
196
  const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5))
181
197
  /** Distance that maps to the outer rim; leaves headroom past the 0.65 band. */
182
198
  const R_MAX_DIST = 0.8
199
+ /** Band thresholds, in the same numbers the tables print. */
200
+ const BANDS = [
201
+ { at: 0.35, key: 'yellow', label: '有偏离' },
202
+ { at: 0.50, key: 'orange', label: '偏题' },
203
+ { at: 0.65, key: 'red', label: '严重' }
204
+ ]
205
+ /** Edge classes: same topic vs the same request asked twice. */
206
+ const LINK_STRONG = 0.18
207
+ const LINK_WEAK = 0.42
208
+ /** Wedge at 12 o'clock where the time axis wraps (old→new seam). */
209
+ const ANGLE_SEAM = Math.PI * 0.16
183
210
 
184
211
  /** Stable 32-bit string hash → [0,1). Keeps each goal's jitter constant. */
185
212
  function hash01(text) {
@@ -192,12 +219,65 @@ window.__ModuleLoader__.load({
192
219
  return ((hh >>> 0) % 100000) / 100000
193
220
  }
194
221
 
222
+ /**
223
+ * Sessions by start time → clockwise angle from 12 o'clock.
224
+ *
225
+ * Angle used to be pure decoration (golden-angle spacing), which wasted a
226
+ * whole visual channel: you could not tell "drifted late" from "drifted,
227
+ * got pulled back, drifted again". With the angle ordered by start time,
228
+ * reading clockwise answers exactly that.
229
+ *
230
+ * Rank-based, not time-proportional, on purpose: real start times here
231
+ * span a week with a 10 ms minimum gap, so mapping times linearly puts
232
+ * dozens of goals on the same ray. Equal angular spacing keeps the field
233
+ * readable and the old→new order exact; the spacing encodes ORDER, not the
234
+ * size of the time gaps (that is what the tooltip timestamp is for).
235
+ */
236
+ function angleByStartTime(entries) {
237
+ const ordered = [...entries].sort((a, b) => {
238
+ const ta = typeof a.startedAt === 'number' ? a.startedAt : Infinity
239
+ const tb = typeof b.startedAt === 'number' ? b.startedAt : Infinity
240
+ if (ta !== tb) return ta - tb
241
+ // Same instant: stable, deterministic tie-break so a re-render cannot
242
+ // shuffle two goals that started together.
243
+ return String(a.id).localeCompare(String(b.id))
244
+ })
245
+ const out = new Map()
246
+ if (!ordered.some(e => typeof e.startedAt === 'number')) {
247
+ // No timestamps at all: keep a deterministic spiral instead of a
248
+ // meaningless time axis.
249
+ ordered.forEach((e, i) => {
250
+ out.set(e.id, i * GOLDEN_ANGLE + (hash01(e.id) - 0.5) * 0.12)
251
+ })
252
+ return out
253
+ }
254
+ const n = ordered.length
255
+ // A full turn would put the newest goal back on the oldest one's ray, so
256
+ // "clockwise = later" would be false for the last point. A half turn
257
+ // wastes the left side. The answer is a full turn with a reserved seam:
258
+ // time runs over 360° minus a small wedge at the top, and the seam is
259
+ // drawn explicitly as the old→new arrow, so the wrap is visible instead
260
+ // of being a coordinate trick.
261
+ const span = Math.PI * 2 - ANGLE_SEAM
262
+ ordered.forEach((e, i) => {
263
+ const f = n === 1 ? 0 : i / (n - 1)
264
+ out.set(e.id, -Math.PI / 2 + ANGLE_SEAM / 2 + f * span)
265
+ })
266
+ return out
267
+ }
268
+
195
269
  /** Distance payload → normalized polar points. */
196
270
  function layoutPoints(items) {
197
271
  const scored = (items ?? []).filter(g => typeof g.meanDistance === 'number')
198
272
  const sorted = [...scored].sort((a, b) => b.meanDistance - a.meanDistance)
273
+ const entries = sorted.map((g, i) => ({
274
+ id: g.goalId,
275
+ startedAt: g.sessionStartedAt ?? g.createdAt ?? null,
276
+ // Golden-angle order is only the fallback for untimed data.
277
+ seedIndex: i
278
+ }))
279
+ const angles = angleByStartTime(entries)
199
280
  return sorted.map((g, i) => {
200
- const jitter = hash01(g.goalId) - 0.5
201
281
  return {
202
282
  id: g.goalId,
203
283
  objective: g.objective ?? '',
@@ -206,7 +286,8 @@ window.__ModuleLoader__.load({
206
286
  calls: g.totalCalls ?? 0,
207
287
  failed: g.failedCalls ?? 0,
208
288
  band: band(g.meanDistance)[1],
209
- angle: i * GOLDEN_ANGLE + jitter * 0.55,
289
+ startedAt: entries[i].startedAt,
290
+ angle: angles.get(g.goalId) ?? i * GOLDEN_ANGLE,
210
291
  // The floor keeps a well-focused goal off the core sprite, which would
211
292
  // otherwise read as the core itself and hide the light.
212
293
  radius: Math.max(0.13, Math.min(1, g.meanDistance / R_MAX_DIST)),
@@ -216,6 +297,39 @@ window.__ModuleLoader__.load({
216
297
  })
217
298
  }
218
299
 
300
+ /**
301
+ * Drop the decorative filaments.
302
+ *
303
+ * The old spiral curves read as "the trajectory of the conversation" and
304
+ * encoded nothing — a static picture cannot say whether they were real
305
+ * turns or art. They are gone; real turn trajectories live in the
306
+ * 漂移轮次 tab. What replaces them is real adjacency between goals.
307
+ */
308
+
309
+ /**
310
+ * Resolve the similarity links into drawable segments.
311
+ *
312
+ * `links` comes from src/distance.js (goal vectors, 1−cos), so the edges
313
+ * are data, not decoration. Anything without a rendered endpoint is
314
+ * skipped rather than guessed at.
315
+ */
316
+ function layoutLinks(links, positionById) {
317
+ const out = []
318
+ for (const l of links ?? []) {
319
+ const a = positionById.get(l.a)
320
+ const b = positionById.get(l.b)
321
+ if (!a || !b) continue
322
+ if (typeof l.distance !== 'number') continue
323
+ out.push({
324
+ a: l.a,
325
+ b: l.b,
326
+ distance: l.distance,
327
+ strong: l.distance <= LINK_STRONG
328
+ })
329
+ }
330
+ return out
331
+ }
332
+
219
333
  /**
220
334
  * Sparks around one goal point: the sprayed-light texture of the reference.
221
335
  *
@@ -271,10 +385,194 @@ window.__ModuleLoader__.load({
271
385
  }
272
386
  const DUST = makeDust(260, 20261027)
273
387
 
388
+ /**
389
+ * Pick the bearing for the ring chips where they collide with least.
390
+ *
391
+ * Ring chips are drawn on the rings themselves, so a fixed bearing will
392
+ * eventually sit on top of a light once sessions accumulate. Counting by
393
+ * angle alone is not enough: a chip covers ~46×13px, so a goal at a
394
+ * neighbouring bearing but a different radius still overlaps it. This
395
+ * scores candidate bearings against the ACTUAL projected point positions
396
+ * and picks the cheapest.
397
+ *
398
+ * The seam wedge is excluded on purpose: it is empty BY CONSTRUCTION, so
399
+ * without the filter every render would park the chips in the old→new
400
+ * seam and collide with its labels.
401
+ */
402
+ /**
403
+ * Pick the bearing for one ring chip that collides least.
404
+ *
405
+ * Ring chips are drawn on the rings themselves, so a fixed bearing will
406
+ * eventually sit on top of a light once sessions accumulate. Counting by
407
+ * angle alone is not enough: a chip covers ~46×13px, so a goal at a
408
+ * neighbouring bearing but a different radius still overlaps it. This
409
+ * scores candidate bearings against the ACTUAL projected point positions
410
+ * and picks the cheapest.
411
+ *
412
+ * Value labels count too. Severe-band points carry a permanent three-digit
413
+ * label pinned at a fixed offset, and it used to be invisible to this
414
+ * function — so the chips could park on top of it (measured: one collision
415
+ * on 31 goals, while 66 of 72 bearings were clear).
416
+ *
417
+ * Chips already placed are passed in as boxes: one bearing shared by all
418
+ * three rings put them on a single ray, where they crowd each other. On a
419
+ * dense field no bearing is fully clear anyway (measured: 0 of 59 score
420
+ * zero with 31 goals), so each ring picks its own instead.
421
+ *
422
+ * The seam wedge is excluded on purpose: it is empty BY CONSTRUCTION, so
423
+ * without the filter every render would park the chips in the old→new
424
+ * seam and collide with its labels.
425
+ *
426
+ * @param ringRadius radius of the ring this chip sits on
427
+ * @param placedChips boxes ({x0,y0,x1,y1}) of chips already drawn
428
+ */
429
+ function emptiestAngle(points, cx, cy, maxR, ringRadius = (0.5 / R_MAX_DIST) * maxR, placedChips = []) {
430
+ const CANDIDATES = 72
431
+ const CHIP_W = 26
432
+ const CHIP_H = 12
433
+ const LABEL_W = 34
434
+ const LABEL_H = 11
435
+ const seamCentre = ((-Math.PI / 2) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2)
436
+ const seamHalf = ANGLE_SEAM / 2 + 0.30
437
+
438
+ const pointPos = points.map(p => ({
439
+ x: cx + Math.cos(p.angle) * p.radius * maxR,
440
+ y: cy + Math.sin(p.angle) * p.radius * maxR * 0.9
441
+ }))
442
+
443
+ // Mirrors the value-label placement in drawField(): severe band only,
444
+ // anchored left-of-top at (x + size + 8, y - (size + 8) * 0.55).
445
+ const labelBoxes = points
446
+ .filter(p => p.band === 'red')
447
+ .map(p => {
448
+ const pos = { x: cx + Math.cos(p.angle) * p.radius * maxR, y: cy + Math.sin(p.angle) * p.radius * maxR * 0.9 }
449
+ const off = p.size + 8
450
+ return {
451
+ x0: pos.x + off,
452
+ x1: pos.x + off + LABEL_W,
453
+ y0: pos.y - off * 0.55 - LABEL_H / 2,
454
+ y1: pos.y - off * 0.55 + LABEL_H / 2
455
+ }
456
+ })
457
+
458
+ // Two penalties with deliberately different shapes:
459
+ //
460
+ // - Points use the ORIGINAL centre-distance form (`|Δ| - full size`).
461
+ // Rewriting it in box terms is not equivalent — the box form penalises
462
+ // a ring of offsets the original ignores, which made chips avoid lights
463
+ // far too strongly and shove themselves into a crowded bearing.
464
+ // Verified numerically: at Δx=44 the original scores 0, the box form
465
+ // scores 169.
466
+ // - Labels use a box test, because a label is a rectangle anchored at a
467
+ // corner rather than a centred sprite, and centre-distance against its
468
+ // anchor puts the penalty in the wrong place.
469
+ const pointCost = (q, lx, ly) => {
470
+ const dx = Math.max(0, Math.abs(q.x - lx) - CHIP_W)
471
+ const dy = Math.max(0, Math.abs(q.y - ly) - CHIP_H)
472
+ const d = Math.hypot(dx, dy)
473
+ return d < 18 ? (18 - d) * (18 - d) : 0
474
+ }
475
+ const boxCost = (x0, y0, x1, y1, q) => {
476
+ const dx = Math.max(0, Math.max(x0 - q.x1, q.x0 - x1))
477
+ const dy = Math.max(0, Math.max(y0 - q.y1, q.y0 - y1))
478
+ const d = Math.hypot(dx, dy)
479
+ return d < 18 ? (18 - d) * (18 - d) : 0
480
+ }
481
+
482
+ let best = -Math.PI / 2
483
+ let bestScore = Infinity
484
+ for (let c = 0; c < CANDIDATES; c++) {
485
+ const a = (c / CANDIDATES) * Math.PI * 2
486
+ let delta = Math.abs(a - seamCentre)
487
+ if (delta > Math.PI) delta = Math.PI * 2 - delta
488
+ if (delta < seamHalf) continue
489
+
490
+ const lx = cx + Math.cos(a) * ringRadius
491
+ const ly = cy + Math.sin(a) * ringRadius * 0.9
492
+ const x0 = lx - CHIP_W / 2
493
+ const x1 = lx + CHIP_W / 2
494
+ const y0 = ly - CHIP_H / 2
495
+ const y1 = ly + CHIP_H / 2
496
+
497
+ let score = 0
498
+ for (const q of pointPos) score += pointCost(q, lx, ly)
499
+ for (const q of labelBoxes) score += boxCost(x0, y0, x1, y1, q)
500
+ // Already-placed chips are scored harder than background noise: two
501
+ // chips overlapping each other is far worse than a chip near a light,
502
+ // because both become unreadable rather than merely busy.
503
+ for (const q of placedChips) score += boxCost(x0, y0, x1, y1, q) * 3
504
+ /*
505
+ * Angular separation, independent of the box test.
506
+ *
507
+ * Chips sit on different radii, so two chips 3° apart can be
508
+ * box-disjoint and still read as one clump — measured: orange at 102°
509
+ * and red at 99° on real data. Penalising near-identical bearings
510
+ * spreads the three chips around the field instead of stacking them.
511
+ */
512
+ for (const q of placedChips) {
513
+ if (q.bearing === undefined) continue
514
+ let sep = Math.abs(a - q.bearing)
515
+ if (sep > Math.PI) sep = Math.PI * 2 - sep
516
+ if (sep < MIN_CHIP_SEP) score += (MIN_CHIP_SEP - sep) * 900
517
+ }
518
+ // Keep the chips away from the rim and the core so they stay on their
519
+ // own ring instead of drifting into other geometry.
520
+ if (lx < 30 || lx > cx * 2 - 30) score += 400
521
+ if (score < bestScore) { bestScore = score; best = a }
522
+ }
523
+ return best
524
+ }
525
+
526
+ /** Font stack for canvas: CJK-capable first, then mono for digits. */
527
+ const FIELD_FONT = '"Microsoft YaHei", "PingFang SC", ui-monospace, Consolas, monospace'
528
+
529
+ /**
530
+ * Chip plate half-extents, shared by the chip drawer and the bearing picker.
531
+ * drawChip() sizes the plate by text width (at least 46×13); these are the
532
+ * conservative halves used for collision scoring.
533
+ */
534
+ const CHIP_HALF_W = 23
535
+ const CHIP_HALF_H = 7
536
+ /** Minimum bearing gap between two ring chips, in radians (~22 degrees). */
537
+ const MIN_CHIP_SEP = Math.PI * 0.12
538
+
539
+ /**
540
+ * A tiny labelled pill, used for the ring thresholds.
541
+ *
542
+ * Canvas has no layout, so this draws the plate itself and keeps the text
543
+ * legible over the starfield: a bare 9px number at 55% alpha was invisible
544
+ * in practice, which is why the rings looked unlabelled.
545
+ */
546
+ function drawChip(g, x, y, text, rgb) {
547
+ g.font = '9px ' + FIELD_FONT
548
+ g.textAlign = 'center'
549
+ g.textBaseline = 'middle'
550
+ const w = Math.max(46, g.measureText(text).width + 10)
551
+ const hgt = 13
552
+ g.beginPath()
553
+ g.fillStyle = 'rgba(10,17,24,0.82)'
554
+ g.strokeStyle = 'rgba(' + rgb.join(',') + ',0.55)'
555
+ g.lineWidth = 0.8
556
+ // roundRect is not in every canvas build; fall back to a plain rect.
557
+ if (typeof g.roundRect === 'function') {
558
+ g.roundRect(x - w / 2, y - hgt / 2, w, hgt, 6)
559
+ } else {
560
+ g.rect(x - w / 2, y - hgt / 2, w, hgt)
561
+ }
562
+ g.fill()
563
+ g.stroke()
564
+ g.fillStyle = 'rgba(' + rgb.join(',') + ',0.95)'
565
+ g.fillText(text, x, y)
566
+ }
567
+
274
568
  /**
275
569
  * Paint one frame. Returns projected points so the caller can hit-test.
570
+ *
571
+ * `links` carries the goal-to-goal similarity edges; the caller resolves
572
+ * them against the same projection the points use, so an edge can never
573
+ * land next to a point that is not actually there.
276
574
  */
277
- function drawField(g, width, height, points, time, hoverId) {
575
+ function drawField(g, width, height, points, time, hoverId, links = []) {
278
576
  const cx = width / 2
279
577
  const cy = height / 2
280
578
  const maxR = Math.min(width * 0.42, height * 0.46)
@@ -320,49 +618,118 @@ window.__ModuleLoader__.load({
320
618
  g.fill()
321
619
  }
322
620
 
323
- // --- band rings ------------------------------------------------------
324
- // Labels sit on the upper-left diagonal, where the ring is empty, instead
325
- // of at 3 o'clock where the goal points crowd the rim.
326
- for (const ring of [0.35, 0.5, 0.65]) {
327
- const r = (ring / R_MAX_DIST) * maxR
621
+ // --- band rings, labelled --------------------------------------------
622
+ // The rings used to be unlabelled hairlines: the four bands were only
623
+ // inferable from colour, and colour was exactly the thing that failed.
624
+ // Numbers and band names now sit ON the ring, in the same notation the
625
+ // tables use, so a reader never has to reverse-engineer a colour.
626
+ //
627
+ // The label bearing is picked per render from the emptiest sector rather
628
+ // than hardcoded: goal positions move as sessions accumulate, and a fixed
629
+ // diagonal eventually lands a chip on top of a light.
630
+ //
631
+ // Each ring is placed independently, and each placement also avoids the
632
+ // chips already placed. One shared bearing forced all three chips onto a
633
+ // single ray, where they crowded each other and together sat on whichever
634
+ // light happened to be on that bearing — with 31 goals the field is dense
635
+ // enough that no single bearing is fully clear (measured: 0 of 59
636
+ // candidates score zero), so sharing one makes the collision certain
637
+ // instead of merely likely.
638
+ const placedChips = []
639
+ for (const b of BANDS) {
640
+ const r = (b.at / R_MAX_DIST) * maxR
328
641
  g.beginPath()
329
- g.setLineDash([1, 6])
330
- g.strokeStyle = ring >= 0.65
331
- ? 'rgba(150,190,222,0.26)'
332
- : 'rgba(120,158,192,0.15)'
642
+ g.setLineDash([1, 5])
643
+ g.strokeStyle = 'rgba(' + (BAND_GLOW[b.key] ?? BAND_GLOW.grey).join(',') + ',0.30)'
333
644
  g.lineWidth = 1
334
645
  g.ellipse(cx, cy, r, r * 0.9, 0, 0, Math.PI * 2)
335
646
  g.stroke()
336
647
  g.setLineDash([])
337
648
 
338
- const la = Math.PI * 1.18
339
- g.font = '9px ui-monospace, Consolas, monospace'
340
- g.fillStyle = 'rgba(150,186,214,0.55)'
341
- g.textAlign = 'center'
342
- g.textBaseline = 'middle'
343
- g.fillText(ring.toFixed(2), cx + Math.cos(la) * r, cy + Math.sin(la) * r * 0.9)
649
+ const labelAngle = emptiestAngle(points, cx, cy, maxR, r, placedChips)
650
+ const lx = cx + Math.cos(labelAngle) * r
651
+ const ly = cy + Math.sin(labelAngle) * r * 0.9
652
+ drawChip(g, lx, ly, b.at.toFixed(2) + ' ' + b.label, BAND_GLOW[b.key] ?? BAND_GLOW.grey)
653
+ // Same plate dimensions drawChip() uses; measured here would need the
654
+ // context font, and a fixed conservative box is enough for avoidance.
655
+ placedChips.push({ x0: lx - CHIP_HALF_W, x1: lx + CHIP_HALF_W, y0: ly - CHIP_HALF_H, y1: ly + CHIP_HALF_H, bearing: labelAngle })
344
656
  }
345
657
 
346
- // --- filaments: the light's threads ----------------------------------
347
- // Each one is drawn three times at widening width and falling alpha, which
348
- // gives the soft bloom of the reference instead of a hard hairline.
658
+ // --- the time axis ----------------------------------------------------
659
+ // Angle means session start time, clockwise from 12 o'clock. The seam at
660
+ // the top is where the axis wraps from newest back to oldest, so it is
661
+ // drawn rather than left implicit: without it the last point would look
662
+ // "earlier" than the first.
663
+ const seamR = maxR * 1.02
664
+ g.beginPath()
665
+ g.strokeStyle = 'rgba(160,194,222,0.22)'
666
+ g.lineWidth = 1
667
+ g.setLineDash([2, 4])
668
+ g.moveTo(cx + Math.cos(-Math.PI / 2 + ANGLE_SEAM / 2) * maxR * 0.18,
669
+ cy + Math.sin(-Math.PI / 2 + ANGLE_SEAM / 2) * maxR * 0.18 * 0.9)
670
+ g.lineTo(cx + Math.cos(-Math.PI / 2 + ANGLE_SEAM / 2) * seamR,
671
+ cy + Math.sin(-Math.PI / 2 + ANGLE_SEAM / 2) * seamR * 0.9)
672
+ g.moveTo(cx + Math.cos(-Math.PI / 2 - ANGLE_SEAM / 2) * maxR * 0.18,
673
+ cy + Math.sin(-Math.PI / 2 - ANGLE_SEAM / 2) * maxR * 0.18 * 0.9)
674
+ g.lineTo(cx + Math.cos(-Math.PI / 2 - ANGLE_SEAM / 2) * seamR,
675
+ cy + Math.sin(-Math.PI / 2 - ANGLE_SEAM / 2) * seamR * 0.9)
676
+ g.stroke()
677
+ g.setLineDash([])
678
+
679
+ g.font = '9px ' + FIELD_FONT
680
+ g.textAlign = 'center'
681
+ g.textBaseline = 'middle'
682
+ g.fillStyle = 'rgba(160,194,222,0.62)'
683
+ g.fillText('旧', cx - maxR * 0.20, cy - maxR * 1.02)
684
+ g.fillText('新', cx + maxR * 0.20, cy - maxR * 1.02)
685
+ g.fillStyle = 'rgba(160,194,222,0.34)'
686
+ g.fillText('顺时针 = 更晚', cx, cy + maxR * 1.16)
687
+
688
+ // --- goal geometry, shared by the link layer and the point pass -------
689
+ const posById = new Map()
349
690
  for (const p of points) {
350
- const x = cx + Math.cos(p.angle) * p.radius * maxR
351
- const y = cy + Math.sin(p.angle) * p.radius * maxR * 0.9
352
- const bend = 0.34 + (hash01(p.id) - 0.5) * 0.5
353
- const mx = cx + Math.cos(p.angle + bend) * p.radius * maxR * 0.52
354
- const my = cy + Math.sin(p.angle + bend) * p.radius * maxR * 0.52 * 0.9
355
- const hot = p.band === 'red' || p.band === 'orange'
356
- const base = hot ? [255, 198, 96] : [150, 186, 214]
357
- const strength = hot ? 1 : 0.5
358
- for (let layer = 0; layer < 3; layer++) {
691
+ posById.set(p.id, {
692
+ x: cx + Math.cos(p.angle) * p.radius * maxR,
693
+ y: cy + Math.sin(p.angle) * p.radius * maxR * 0.9
694
+ })
695
+ }
696
+
697
+ // --- links: similarity BETWEEN goals ---------------------------------
698
+ // This is what makes the field a network instead of a star. It replaces
699
+ // the old decorative filaments, which connected every goal to the core
700
+ // and encoded nothing: a static picture cannot distinguish "real turn
701
+ // trajectory" from "art", so it read as data it did not carry.
702
+ for (const l of links) {
703
+ const a = posById.get(l.a)
704
+ const bpos = posById.get(l.b)
705
+ if (!a || !bpos) continue
706
+ const hot = hoverId === l.a || hoverId === l.b
707
+ // Bow the line so overlapping edges stay countable, deterministically.
708
+ const bendSign = hash01(l.a + '|' + l.b) > 0.5 ? 1 : -1
709
+ const mx = (a.x + bpos.x) / 2 + (bpos.y - a.y) * 0.12 * bendSign
710
+ const my = (a.y + bpos.y) / 2 + (a.x - bpos.x) * 0.12 * bendSign
711
+ const base = l.strong ? [255, 214, 140] : [140, 186, 220]
712
+ const alpha = l.strong
713
+ ? (hot ? 0.62 : 0.30)
714
+ : (hot ? 0.34 : 0.13)
715
+ g.beginPath()
716
+ g.moveTo(a.x, a.y)
717
+ g.quadraticCurveTo(mx, my, bpos.x, bpos.y)
718
+ g.strokeStyle = 'rgba(' + base.join(',') + ',' + alpha + ')'
719
+ g.lineWidth = l.strong ? (hot ? 2 : 1.3) : (hot ? 1.4 : 0.8)
720
+ g.stroke()
721
+
722
+ // A single travelling spark on "same request asked twice" edges: the
723
+ // reuse is the point of those links, so they get a pulse, not a glow.
724
+ if (l.strong) {
725
+ const u = ((t * 0.12 + hash01(l.a + l.b)) % 1)
726
+ const iu = 1 - u
727
+ const sx = iu * iu * a.x + 2 * iu * u * mx + u * u * bpos.x
728
+ const sy = iu * iu * a.y + 2 * iu * u * my + u * u * bpos.y
359
729
  g.beginPath()
360
- g.moveTo(cx, cy)
361
- g.quadraticCurveTo(mx, my, x, y)
362
- g.strokeStyle = 'rgba(' + base.join(',') + ',' +
363
- ((hot ? 0.10 : 0.055) * strength * (1 - layer * 0.32)).toFixed(3) + ')'
364
- g.lineWidth = 0.6 + layer * 1.7
365
- g.stroke()
730
+ g.fillStyle = 'rgba(255,236,186,' + (hot ? 0.95 : 0.72) + ')'
731
+ g.arc(sx, sy, hot ? 1.9 : 1.3, 0, Math.PI * 2)
732
+ g.fill()
366
733
  }
367
734
  }
368
735
 
@@ -413,8 +780,9 @@ window.__ModuleLoader__.load({
413
780
  // --- goal points -----------------------------------------------------
414
781
  const projected = []
415
782
  for (const p of points) {
416
- const x = cx + Math.cos(p.angle) * p.radius * maxR
417
- const y = cy + Math.sin(p.angle) * p.radius * maxR * 0.9
783
+ const pos = posById.get(p.id)
784
+ const x = pos.x
785
+ const y = pos.y
418
786
  projected.push({ point: p, x, y })
419
787
 
420
788
  const rgb = BAND_GLOW[p.band] ?? BAND_GLOW.grey
@@ -460,6 +828,25 @@ window.__ModuleLoader__.load({
460
828
  }
461
829
  }
462
830
 
831
+ // --- permanent numbers, severe band only ------------------------------
832
+ // One rule for the whole field: the severe (bright gold) points always
833
+ // print their distance, everything else is on hover. Previously three
834
+ // numbers floated over the left side with no visible rule behind them,
835
+ // which read as arbitrary; this makes "why is this one labelled" obvious.
836
+ g.font = '10px ' + FIELD_FONT
837
+ g.textAlign = 'left'
838
+ g.textBaseline = 'middle'
839
+ for (const p of points) {
840
+ if (p.band !== 'red') continue
841
+ const pos = posById.get(p.id)
842
+ if (!pos) continue
843
+ const isHover = hoverId === p.id
844
+ const label = p.distance.toFixed(3)
845
+ const off = (p.size + 8)
846
+ g.fillStyle = isHover ? 'rgba(255,240,205,0.98)' : 'rgba(255,224,158,0.88)'
847
+ g.fillText(label, pos.x + off, pos.y - off * 0.55)
848
+ }
849
+
463
850
  return projected
464
851
  }
465
852
 
@@ -467,6 +854,7 @@ window.__ModuleLoader__.load({
467
854
  const canvasRef = useRef(null)
468
855
  const wrapRef = useRef(null)
469
856
  const pointsRef = useRef([])
857
+ const linksRef = useRef([])
470
858
  const hoverRef = useRef(null)
471
859
  const [hover, setHover] = useState(null)
472
860
  const [tip, setTip] = useState(null)
@@ -477,6 +865,26 @@ window.__ModuleLoader__.load({
477
865
  )
478
866
  pointsRef.current = points
479
867
 
868
+ // Links are resolved once per snapshot, not per frame: they only depend on
869
+ // which goals are present, and the draw loop should stay allocation-light.
870
+ const links = useMemo(() => {
871
+ const byId = new Map(points.map(p => [p.id, p]))
872
+ return layoutLinks(distance ? distance.links : [], byId)
873
+ }, [distance, points])
874
+ linksRef.current = links
875
+
876
+ // How many goals share each strong link — used to name the reuse idea in
877
+ // the tooltip instead of leaving the bright edges unexplained.
878
+ const reuseById = useMemo(() => {
879
+ const m = new Map()
880
+ for (const l of links) {
881
+ if (!l.strong) continue
882
+ m.set(l.a, (m.get(l.a) ?? 0) + 1)
883
+ m.set(l.b, (m.get(l.b) ?? 0) + 1)
884
+ }
885
+ return m
886
+ }, [links])
887
+
480
888
  useEffect(() => {
481
889
  const canvas = canvasRef.current
482
890
  if (!canvas || !points.length) return undefined
@@ -505,21 +913,25 @@ window.__ModuleLoader__.load({
505
913
  const frame = (time) => {
506
914
  if (!alive) return
507
915
  resize()
508
- const projected = drawField(ctx2d, lastW, lastH, pointsRef.current, time, hoverRef.current)
916
+ const projected = drawField(
917
+ ctx2d, lastW, lastH, pointsRef.current, time, hoverRef.current, linksRef.current
918
+ )
509
919
  canvas.__glPoints = projected
510
920
  raf = window.requestAnimationFrame(frame)
511
921
  }
512
922
 
513
923
  resize()
514
924
  // First paint is synchronous so the panel is never briefly empty.
515
- canvas.__glPoints = drawField(ctx2d, lastW, lastH, pointsRef.current, 0, null)
925
+ canvas.__glPoints = drawField(
926
+ ctx2d, lastW, lastH, pointsRef.current, 0, null, linksRef.current
927
+ )
516
928
  raf = window.requestAnimationFrame(frame)
517
929
 
518
930
  return () => {
519
931
  alive = false
520
932
  if (raf) window.cancelAnimationFrame(raf)
521
933
  }
522
- }, [points.length])
934
+ }, [points.length, links.length])
523
935
 
524
936
  const onMove = useCallback((e) => {
525
937
  const canvas = canvasRef.current
@@ -577,6 +989,18 @@ window.__ModuleLoader__.load({
577
989
  : b === 'yellow' ? '有偏离 0.35+'
578
990
  : b === 'orange' ? '偏题 0.50+' : '严重 0.65+'
579
991
  )
992
+ ),
993
+ // The link classes only exist once a snapshot carries `links`. A
994
+ // cached snapshot from before this layer landed has none, and showing
995
+ // the key for edges that are not on screen would be a lie.
996
+ links.length > 0 && h('span', { className: 'gl-legend-sep' }, '|'),
997
+ links.length > 0 && h('span', null,
998
+ h('i', { className: 'gl-legend-line gl-legend-line-weak' }),
999
+ '同主题 ≤' + LINK_WEAK.toFixed(2)
1000
+ ),
1001
+ links.length > 0 && h('span', null,
1002
+ h('i', { className: 'gl-legend-line gl-legend-line-strong' }),
1003
+ '几乎同题 ≤' + LINK_STRONG.toFixed(2)
580
1004
  )
581
1005
  ),
582
1006
  tip && h('div', {
@@ -588,9 +1012,16 @@ window.__ModuleLoader__.load({
588
1012
  h('span', { className: 'gl-tag gl-tag-' + tip.point.band }, band(tip.point.distance)[0]),
589
1013
  h('span', { className: 'gl-tip-obj' }, tip.point.objective),
590
1014
  h('span', { className: 'gl-tip-meta' },
591
- `${tip.point.turns} 轮 · ${tip.point.calls} 次调用 · ${tip.point.failed} 次失败`)
1015
+ `${tip.point.turns} 轮 · ${tip.point.calls} 次调用 · ${tip.point.failed} 次失败`
1016
+ + (tip.point.startedAt ? ` · 开始于 ${fmtTime(tip.point.startedAt)}` : '')
1017
+ + (reuseCountOf(tip.point.id) ? ` · 与 ${reuseCountOf(tip.point.id)} 个目标几乎同题(可复用上次路径)` : '')
1018
+ )
592
1019
  )
593
1020
  )
1021
+
1022
+ function reuseCountOf(id) {
1023
+ return reuseById.get(id) ?? 0
1024
+ }
594
1025
  }
595
1026
 
596
1027
  // ---------------------------------------------------------------------
@@ -1141,7 +1572,9 @@ window.__ModuleLoader__.load({
1141
1572
  tab === 'field' && h(React.Fragment, null,
1142
1573
  h('div', { className: 'gl-section-title' }, '漂移场(半径 = 距离目标的远近)'),
1143
1574
  h('div', { className: 'gl-muted', style: { marginBottom: 8 } },
1144
- '中心亮点是「最初的目标」,每个光点是一个目标在整段会话里偏离它的程度 —— 越靠外越远。颜色与下方表格的档位一致。'),
1575
+ '中心亮点是「最初的目标」。每个光点是一个目标,半径 = 它整段会话里可计分轮次的平均距离,越靠外离最初的要求越远;'
1576
+ + '角度按会话开始时间顺时针排列(顶部接缝是旧→新的回绕处);光点之间的连线 = 目标之间的相似度,'
1577
+ + '亮线代表几乎是同一个目标被重复提问,上次的路径通常可以直接复用。'),
1145
1578
  h(DriftField, { distance: snap && snap.distance })
1146
1579
  ),
1147
1580
 
package/market-index.json CHANGED
@@ -81,8 +81,8 @@
81
81
  },
82
82
  "versions": [
83
83
  {
84
- "version": "0.2.2",
85
- "publishTimestamp": 1793169063,
84
+ "version": "0.3.0",
85
+ "publishTimestamp": 1793172958,
86
86
  "source": {
87
87
  "type": "npm"
88
88
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-goal-ledger",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Goal-distance ledger and failure-cluster panel for DeepSeek Harness — audits your own sessions, shows where goals drifted and where tool calls actually failed.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,9 +58,12 @@
58
58
  "test:correction": "node test/correction.mjs && node test/correction-wiring.mjs",
59
59
  "test:full": "node test/full.mjs",
60
60
  "test:render-field": "node test/render-field.mjs",
61
+ "test:links": "node test/links.mjs",
61
62
  "check": "node --check lib/index.js && node --check lib/client.js && node --check src/session-reader.js && node --check src/failure-cluster.js && node --check src/embed-config.js && node --check src/correction.js && node --check src/distance.js",
62
63
  "test:manifest": "node test/manifest.mjs",
63
- "test:published": "node test/published-matches-tag.mjs"
64
+ "test:published": "node test/published-matches-tag.mjs",
65
+ "test:labels": "node test/label-collisions.mjs",
66
+ "test:bearing": "node test/bearing-cost.mjs"
64
67
  },
65
68
  "devDependencies": {
66
69
  "jsdom": "^26.1.0",
package/src/distance.js CHANGED
@@ -57,6 +57,115 @@ function cosineDistance(a, b) {
57
57
 
58
58
  export { cosineDistance }
59
59
 
60
+ /**
61
+ * Goal-to-goal links: the point-to-point edges the chart is missing today.
62
+ *
63
+ * Every goal already has a vector — its objective was embedded in the same
64
+ * scoring pass as its turns — but the chart only ever draws goal -> turn rays,
65
+ * so every goal connects to its own centre and nothing else. That hides the one
66
+ * pattern worth acting on: "similar failures cluster" (several goals drifting
67
+ * the same way) and "successful paths are reusable" (goals that ended up doing
68
+ * nearly the same thing). Both are goal-to-goal questions, and both can be
69
+ * answered from vectors that are already in memory, i.e. at zero extra API
70
+ * cost. This builds those edges offline: pure function, no I/O, no embeddings.
71
+ *
72
+ * Pairs are undirected and deduped by unordered pair. Selection is seeded as
73
+ * the union of each node's nearest surviving pairs — so a pair that is in goal
74
+ * i's top-k but not goal j's is still kept — and then a deterministic hard cap
75
+ * trims any node left above `maxDegree` down to its nearest neighbours. Without
76
+ * that cap one dense hub turns the whole chart into a hairball; the cap is per
77
+ * node, so `maxDegree: 0` means "no links at all". Where a hub's neighbours all
78
+ * pick the hub but the hub already has `maxDegree` closer ones, the cap wins and
79
+ * the farthest of those edges is dropped — a node never exceeds `maxDegree`.
80
+ *
81
+ * Deterministic by construction: distance ties inside a node's neighbourhood
82
+ * break on node index, and the emitted list sorts by distance then `a` then `b`,
83
+ * so two runs over the same input compare deep-equal. Inputs are never mutated.
84
+ *
85
+ * @param vectors number[][] goal objective vectors, index-aligned with `ids`;
86
+ * null/undefined/empty entries are skipped, not errors
87
+ * @param ids string[] goalIds, same length/order as `vectors`
88
+ * @param options.maxDistance cosine-distance ceiling; pairs strictly above it
89
+ * are dropped, so exactly-at-ceiling is kept
90
+ * (default 0.42)
91
+ * @param options.maxDegree per-node cap on kept links (default 3, 0 = off)
92
+ * @returns {{a: string, b: string, distance: number}[]} ascending by distance,
93
+ * ties broken by `a` then `b`; a !== b; distances rounded to 4 dp
94
+ */
95
+ export function buildGoalLinks(vectors, ids, { maxDistance = 0.42, maxDegree = 3 } = {}) {
96
+ const n = Math.min(vectors?.length ?? 0, ids?.length ?? 0)
97
+ if (n < 2) return []
98
+
99
+ const cap = Number.isFinite(maxDegree) ? Math.max(0, Math.floor(maxDegree)) : 0
100
+ if (cap === 0) return []
101
+ const ceiling = Number.isFinite(maxDistance) ? maxDistance : 0.42
102
+ const usable = v => Array.isArray(v) && v.length > 0
103
+
104
+ // 1. Every unordered pair that survives the distance ceiling.
105
+ const candidate = new Map()
106
+ const byNode = new Map()
107
+ for (let i = 0; i < n; i++) {
108
+ if (!usable(vectors[i])) continue
109
+ for (let j = i + 1; j < n; j++) {
110
+ if (!usable(vectors[j])) continue
111
+ const d = cosineDistance(vectors[i], vectors[j])
112
+ if (d > ceiling) continue
113
+ const key = i * n + j
114
+ candidate.set(key, { i, j, d, key })
115
+ if (!byNode.has(i)) byNode.set(i, [])
116
+ if (!byNode.has(j)) byNode.set(j, [])
117
+ byNode.get(i).push({ other: j, key, d })
118
+ byNode.get(j).push({ other: i, key, d })
119
+ }
120
+ }
121
+ if (!candidate.size) return []
122
+
123
+ // 2. Seed with the union of each node's nearest pairs: a pair chosen by only
124
+ // one endpoint still counts (it is that endpoint's neighbour, not a vote).
125
+ const kept = new Set()
126
+ for (const list of byNode.values()) {
127
+ list.sort((x, y) => x.d - y.d || x.other - y.other)
128
+ for (let k = 0; k < list.length && k < cap; k++) kept.add(list[k].key)
129
+ }
130
+
131
+ // 3. Enforce the degree cap for real. The union in step 2 can push a node above
132
+ // `cap` because every neighbour also chose it, so walk the kept edges from
133
+ // farthest to nearest and drop any edge whose endpoint is still over cap.
134
+ // Removing never raises a degree, so one ordered pass bounds every node at
135
+ // `cap` without a fixed-point loop. Farthest goes first on purpose: keeping
136
+ // the strongest edges is the whole point of a similarity layer.
137
+ const keptEdges = [...kept].map(key => candidate.get(key))
138
+ keptEdges.sort((x, y) => y.d - x.d || y.i - x.i || y.j - x.j)
139
+ const degree = new Map()
140
+ for (const e of keptEdges) {
141
+ degree.set(e.i, (degree.get(e.i) ?? 0) + 1)
142
+ degree.set(e.j, (degree.get(e.j) ?? 0) + 1)
143
+ }
144
+ for (const e of keptEdges) {
145
+ if ((degree.get(e.i) ?? 0) <= cap && (degree.get(e.j) ?? 0) <= cap) continue
146
+ kept.delete(e.key)
147
+ degree.set(e.i, degree.get(e.i) - 1)
148
+ degree.set(e.j, degree.get(e.j) - 1)
149
+ }
150
+
151
+ // 4. Emit, sorted on the rounded value actually printed.
152
+ const round = v => Math.round(v * 10000) / 10000
153
+ const out = []
154
+ for (const key of kept) {
155
+ const { i, j, d } = candidate.get(key)
156
+ out.push({ a: ids[i], b: ids[j], distance: round(d) })
157
+ }
158
+ out.sort((x, y) => x.distance - y.distance || cmpStr(x.a, y.a) || cmpStr(x.b, y.b))
159
+ return out
160
+ }
161
+
162
+ /** Code-unit string compare: locale-independent, so ordering is reproducible. */
163
+ function cmpStr(a, b) {
164
+ const x = String(a)
165
+ const y = String(b)
166
+ return x < y ? -1 : x > y ? 1 : 0
167
+ }
168
+
60
169
  /**
61
170
  * Embed a small set of texts with the configured endpoint.
62
171
  *
@@ -226,6 +335,15 @@ export async function scoreGoals(items, options = {}) {
226
335
  if (w.kind === 'turn') turnIndex.set(`${w.gi}:${w.ti}`, i)
227
336
  })
228
337
 
338
+ // Goal objective vectors, in item order. These were embedded above as part of
339
+ // the normal scoring work, so the link layer below costs no extra API calls.
340
+ const goalVectors = []
341
+ const goalIds = []
342
+ for (let gi = 0; gi < items.length; gi++) {
343
+ goalVectors.push(vectors[goalIndex.get(gi)])
344
+ goalIds.push(items[gi].goalId)
345
+ }
346
+
229
347
  const scored = []
230
348
  const worst = []
231
349
  for (let gi = 0; gi < items.length; gi++) {
@@ -261,6 +379,11 @@ export async function scoreGoals(items, options = {}) {
261
379
  completed: goal.completed,
262
380
  cwd: goal.cwd,
263
381
  sessionId: goal.sessionId,
382
+ // Timestamps for the chart's angle encoding. Both already exist upstream
383
+ // (collectGoals / session-reader); absent values stay explicitly null so
384
+ // the client can tell "no data" from "not provided by this version".
385
+ createdAt: goal.createdAt ?? null,
386
+ sessionStartedAt: goal.turns?.[0]?.started ?? null,
264
387
  turnCount: turnsOut.length,
265
388
  scorableTurns: dists.length,
266
389
  failedCalls: goal.failedCalls ?? 0,
@@ -280,6 +403,11 @@ export async function scoreGoals(items, options = {}) {
280
403
  turns: scored.reduce((s, g) => s + g.turnCount, 0),
281
404
  scorableTurns: scored.reduce((s, g) => s + g.scorableTurns, 0),
282
405
  items: scored,
406
+ // Goal-to-goal edges from the vectors already computed above. Empty when
407
+ // no goal is close enough to another — that is a real answer, not an error.
408
+ links: buildGoalLinks(goalVectors, goalIds),
409
+ // Echo the rules so the client and README cannot drift from the server.
410
+ linkRules: { maxDistance: 0.42, maxDegree: 3 },
283
411
  worstTurns: worst.slice(0, 20)
284
412
  }
285
413
  }