@quolu/lattice 0.12.10 → 0.12.12

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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Todo Gantt scope projection.
3
+ *
4
+ * The store carries every completed ToDo forward across plan revisions on
5
+ * purpose — an accepted artifact is the predecessor of the work that follows
6
+ * it. That makes the dependency diagram grow monotonically: once a campaign
7
+ * finishes, its whole tree stays on screen forever and every later revision
8
+ * stacks on top of it. The journal is the source of truth, so nothing is
9
+ * removed from the store; the diagram is a projection, and this module is
10
+ * where that projection narrows.
11
+ *
12
+ * The rule is "fold the dead branches": a completed ToDo that no longer leads
13
+ * to any live work is history, and history collapses into one labelled node
14
+ * per finished branch. Completed ToDos that are still the direct premise of
15
+ * live work stay visible, because they are the context for what is dispatchable
16
+ * right now.
17
+ *
18
+ * This module is pure graph math over the layout's internal node/edge shape.
19
+ * It must run AFTER dependency waves, the longest dependency chain and the
20
+ * ready frontier have been computed on the FULL graph — those numbers describe
21
+ * the real plan, and measuring them on a folded graph would make them lie.
22
+ */
23
+
24
+ export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
25
+
26
+ /**
27
+ * Hops of completed predecessors kept in front of live work. 1 = keep the
28
+ * direct premises of live ToDos, fold everything upstream of them.
29
+ */
30
+ export const DEFAULT_FOLD_DISTANCE = 1;
31
+
32
+ /**
33
+ * `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
34
+ * a leading '~' cannot collide with a real ToDo.
35
+ */
36
+ const FOLD_TASK_ID_PREFIX = '~folded';
37
+
38
+ export class TodoGanttScopeError extends Error {
39
+ constructor(code, message, detail = null) {
40
+ super(message);
41
+ this.name = 'TodoGanttScopeError';
42
+ this.code = code;
43
+ this.detail = detail;
44
+ }
45
+ }
46
+
47
+ function compareText(left, right) {
48
+ return left < right ? -1 : left > right ? 1 : 0;
49
+ }
50
+
51
+ export function isFoldNodeRef(ref) {
52
+ return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
53
+ }
54
+
55
+ /**
56
+ * Forward distance from each node to the nearest live (non-done) node, over the
57
+ * dependency DAG. A live node is at distance 0; a node with no live descendant
58
+ * is at Infinity.
59
+ *
60
+ * Edges always increase the wave (`assignWaves` is a longest-path layering), so
61
+ * visiting nodes in descending wave order guarantees every successor is settled
62
+ * before the node that depends on it.
63
+ */
64
+ function distanceToLive(nodes, edges, wave) {
65
+ const outgoing = new Map(nodes.map(({ key }) => [key, []]));
66
+ for (const edge of edges) outgoing.get(edge.from).push(edge.to);
67
+ const distance = new Map();
68
+ const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
69
+ || compareText(right.key, left.key));
70
+ for (const node of ordered) {
71
+ if (node.status !== 'done') {
72
+ distance.set(node.key, 0);
73
+ continue;
74
+ }
75
+ let best = Infinity;
76
+ for (const successor of outgoing.get(node.key)) {
77
+ const settled = distance.get(successor);
78
+ if (settled !== undefined && settled + 1 < best) best = settled + 1;
79
+ }
80
+ distance.set(node.key, best);
81
+ }
82
+ return distance;
83
+ }
84
+
85
+ function foldableKeys(nodes, distance, foldDistance) {
86
+ return new Set(nodes
87
+ .filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
88
+ .map(({ key }) => key));
89
+ }
90
+
91
+ /**
92
+ * Group foldable nodes into fold units.
93
+ *
94
+ * `byComponent` groups each weakly connected component of the foldable subgraph
95
+ * into one unit — the compact projection, one node per finished branch.
96
+ * `byComponentAndWave` additionally splits each component per dependency wave.
97
+ * The latter is always acyclic once contracted (every dependency edge strictly
98
+ * increases the wave, so contracted edges do too), which makes it the
99
+ * guaranteed-safe refinement when contraction would otherwise close a cycle.
100
+ */
101
+ function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
102
+ const parent = new Map([...foldable].map((key) => [key, key]));
103
+ const find = (key) => {
104
+ let root = key;
105
+ while (parent.get(root) !== root) root = parent.get(root);
106
+ let cursor = key;
107
+ while (parent.get(cursor) !== root) {
108
+ const next = parent.get(cursor);
109
+ parent.set(cursor, root);
110
+ cursor = next;
111
+ }
112
+ return root;
113
+ };
114
+ const union = (left, right) => {
115
+ const leftRoot = find(left);
116
+ const rightRoot = find(right);
117
+ if (leftRoot === rightRoot) return;
118
+ if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
119
+ else parent.set(leftRoot, rightRoot);
120
+ };
121
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
122
+ for (const edge of edges) {
123
+ if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
124
+ // A fold unit never spans plans: the diagram groups by plan and a unit that
125
+ // straddled two plans would have no honest lane to sit in.
126
+ if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
127
+ union(edge.from, edge.to);
128
+ }
129
+
130
+ const unitByNode = new Map();
131
+ const members = new Map();
132
+ for (const key of [...foldable].sort(compareText)) {
133
+ const unitKey = splitByWave
134
+ ? JSON.stringify([find(key), wave.get(key)])
135
+ : JSON.stringify([find(key), null]);
136
+ unitByNode.set(key, unitKey);
137
+ if (!members.has(unitKey)) members.set(unitKey, []);
138
+ members.get(unitKey).push(key);
139
+ }
140
+ return { unitByNode, members };
141
+ }
142
+
143
+ function dominantLane(memberNodes) {
144
+ const counts = new Map();
145
+ for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
146
+ return [...counts.entries()]
147
+ .sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
148
+ || compareText(leftLane, rightLane))[0][0];
149
+ }
150
+
151
+ function buildSummaryNodes(nodes, members, longestChainKeys) {
152
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
153
+ // Order fold units by their first member so synthetic ids are stable across runs.
154
+ const ordered = [...members.entries()]
155
+ .map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
156
+ .sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
157
+
158
+ const summaryByUnit = new Map();
159
+ ordered.forEach((unit, index) => {
160
+ const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
161
+ const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
162
+ const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
163
+ const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
164
+ const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
165
+ summaryByUnit.set(unit.unitKey, {
166
+ key,
167
+ ref,
168
+ title: `完了済み ${memberNodes.length}件`,
169
+ lane: dominantLane(memberNodes),
170
+ status: 'done',
171
+ plan_schema: memberNodes[0].plan_schema,
172
+ phase_id: null,
173
+ phase_status: null,
174
+ phase_ready: false,
175
+ fold: {
176
+ task_count: memberNodes.length,
177
+ lanes,
178
+ longest_chain_task_count: memberNodes
179
+ .filter((node) => longestChainKeys.has(node.key)).length,
180
+ task_refs: memberNodes.map((node) => ({ ...node.ref })),
181
+ },
182
+ });
183
+ });
184
+ return summaryByUnit;
185
+ }
186
+
187
+ function contract(nodes, edges, unitByNode, summaryByUnit) {
188
+ const mapKey = (key) => {
189
+ const unitKey = unitByNode.get(key);
190
+ return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
191
+ };
192
+ const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
193
+ const summaries = [...summaryByUnit.values()];
194
+ const resultNodes = [...keptNodes, ...summaries]
195
+ .sort((left, right) => compareText(left.key, right.key));
196
+
197
+ const contracted = new Map();
198
+ for (const edge of edges) {
199
+ const from = mapKey(edge.from);
200
+ const to = mapKey(edge.to);
201
+ if (from === to) continue; // interior of a fold unit
202
+ const key = JSON.stringify([from, to]);
203
+ let merged = contracted.get(key);
204
+ if (merged === undefined) {
205
+ // A rewired edge is an aggregate. Join identity describes how several
206
+ // premises meet at one ToDo; carrying it onto an aggregate edge would
207
+ // draw a junction marker for a join whose members are no longer on
208
+ // screen, so aggregates keep only the dependency kinds.
209
+ merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
210
+ contracted.set(key, merged);
211
+ }
212
+ for (const kind of edge.kinds) merged.kinds.add(kind);
213
+ if (from === edge.from && to === edge.to) {
214
+ for (const [identityKey, identity] of edge.joinIdentities) {
215
+ merged.joinIdentities.set(identityKey, identity);
216
+ }
217
+ } else {
218
+ merged.aggregated = true;
219
+ }
220
+ }
221
+ for (const edge of contracted.values()) {
222
+ if (edge.aggregated) edge.joinIdentities = new Map();
223
+ }
224
+ const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
225
+ return { nodes: resultNodes, edges: resultEdges };
226
+ }
227
+
228
+ /** Kahn peel; returns true when the graph is acyclic. */
229
+ function isAcyclic(nodes, edges) {
230
+ const indegree = new Map(nodes.map(({ key }) => [key, 0]));
231
+ const outgoing = new Map(nodes.map(({ key }) => [key, []]));
232
+ for (const edge of edges) {
233
+ outgoing.get(edge.from).push(edge.to);
234
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
235
+ }
236
+ const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
237
+ let visited = 0;
238
+ while (ready.length > 0) {
239
+ const current = ready.pop();
240
+ visited += 1;
241
+ for (const next of outgoing.get(current)) {
242
+ indegree.set(next, indegree.get(next) - 1);
243
+ if (indegree.get(next) === 0) ready.push(next);
244
+ }
245
+ }
246
+ return visited === nodes.length;
247
+ }
248
+
249
+ /**
250
+ * Project the full dependency graph onto the `live` scope.
251
+ *
252
+ * Contracting a weakly connected component can close a cycle: with a fold
253
+ * distance of 1 the shape `f1 -> s -> f2` is reachable, where `s` is a
254
+ * completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
255
+ * to the same fold unit. Contracting that unit would produce
256
+ * `summary -> s -> summary`. So the contraction is verified, and on a cycle the
257
+ * grouping is refined to (component, wave) — provably acyclic, because every
258
+ * dependency edge strictly increases the wave.
259
+ *
260
+ * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
261
+ */
262
+ export function projectTodoGanttScope({
263
+ nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
264
+ }) {
265
+ const distance = distanceToLive(nodes, edges, wave);
266
+ const foldable = foldableKeys(nodes, distance, foldDistance);
267
+ if (foldable.size === 0) {
268
+ return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
269
+ }
270
+
271
+ let refined = false;
272
+ let grouping = groupFoldable(nodes, edges, foldable, wave, false);
273
+ let summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
274
+ let contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
275
+ if (!isAcyclic(contracted.nodes, contracted.edges)) {
276
+ refined = true;
277
+ grouping = groupFoldable(nodes, edges, foldable, wave, true);
278
+ summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
279
+ contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
280
+ if (!isAcyclic(contracted.nodes, contracted.edges)) {
281
+ throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
282
+ 'todo gantt scope contraction produced a cycle after per-wave refinement',
283
+ { fold_unit_count: grouping.members.size });
284
+ }
285
+ }
286
+
287
+ const foldedByKey = new Map();
288
+ for (const [nodeKey, unitKey] of grouping.unitByNode) {
289
+ foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
290
+ }
291
+ const folds = [...summaryByUnit.values()]
292
+ .sort((left, right) => compareText(left.key, right.key))
293
+ .map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
294
+
295
+ return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
296
+ }
@@ -140,28 +140,39 @@ function presentationMaps(presentation) {
140
140
  function renderNode(node, maps) {
141
141
  if (!node.visible || node.geometry === null) return '';
142
142
  const { x, y, width, height } = node.geometry;
143
+ const fold = node.fold ?? null;
143
144
  const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
145
+ if (fold !== null) classes.push('folded-node');
144
146
  if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
145
147
  if (node.visibility.active) classes.push('active-node');
146
148
  if (node.visibility.next_ready) classes.push('next-ready-node');
147
149
  if (node.visibility.selected) classes.push('selected-node');
148
150
  const key = nodeKey(node.ref);
149
151
  const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
150
- const status = TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' };
152
+ const status = fold === null
153
+ ? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
154
+ : { mark: '▣', label: '完走済み(畳み込み)' };
151
155
  const lane = maps.lanes.get(nodeLaneKey);
152
- const laneLabel = lane === undefined ? node.lane : `${node.lane}、${lane.name}`;
156
+ const laneLabel = fold === null
157
+ ? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
158
+ : fold.lanes.join('、');
153
159
  const taskNumber = maps.taskNumbers.get(key);
154
- const visibleReference = taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
155
- const spokenReference = taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
160
+ const visibleReference = fold !== null ? `${fold.task_count}工程`
161
+ : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
162
+ const spokenReference = fold !== null ? `${fold.task_count}工程の畳み込み`
163
+ : taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
156
164
  const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
157
- const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。正規ID ${node.ref.plan_key}/${node.ref.task_id}${readyLabel}`;
165
+ const identity = fold === null
166
+ ? `正規ID ${node.ref.plan_key}/${node.ref.task_id}`
167
+ : `${node.ref.plan_key}の完走済み${fold.task_count}工程をまとめたノード。全件を描くには --scope all`;
168
+ const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
158
169
  const statusBar = node.status === 'in-progress'
159
170
  ? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
160
171
  const titleLines = wrapLabel(node.title);
161
172
  const titleMarkup = titleLines.map((line, index) => `<tspan x="${x + 10}" dy="${index === 0 ? 0 : 17}" class="node-title-line">${escapeSvgText(line)}</tspan>`).join('');
162
173
  const taskNumberAttributes = taskNumber === undefined ? ''
163
174
  : ` data-task-number="${escapeSvgAttribute(taskNumber.display_number)}" data-task-number-normalized="${escapeSvgAttribute(taskNumber.normalized_number)}" data-task-number-globally-unique="${taskNumber.globally_unique ? 'true' : 'false'}"`;
164
- return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — 正規ID ${node.ref.plan_key}/${node.ref.task_id}`)}</title></g>`;
175
+ return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
165
176
  }
166
177
 
167
178
  function summaryLabel(value, maximum = 34) {
@@ -1122,13 +1122,14 @@ function resolveTargetedEvent(input, storeMember) {
1122
1122
  if (input.kind === 'done' && exactRecord(input.payload, [
1123
1123
  'done_mode', 'imported', 'evidence',
1124
1124
  ]) && input.payload.done_mode === 'evidence_promotion' && input.payload.imported === true) {
1125
- const target = [...storeMember.journal.events].reverse().find((event) => (
1126
- event.kind === 'done' && event.task_id === input.task_id
1127
- ));
1128
- if (target === undefined) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
1125
+ // Same binding as `reopen`: an imported completion carried across a revision
1126
+ // is bound to the carrying `plan_genesis`, not to a `done` event. Whether the
1127
+ // completion is actually eligible for promotion stays replay's decision.
1128
+ const targetDigest = resolveDoneBindingDigest(storeMember, input.task_id);
1129
+ if (targetDigest === null) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
1129
1130
  return {
1130
1131
  ...input,
1131
- payload: { ...input.payload, target_done_digest: target.event_digest },
1132
+ payload: { ...input.payload, target_done_digest: targetDigest },
1132
1133
  };
1133
1134
  }
1134
1135
  if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {