@llm4ts/flow 2.36.0 → 2.38.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/dist/Domains.d.ts +1 -1
- package/dist/Domains.d.ts.map +1 -1
- package/dist/Domains.js +7 -3
- package/dist/Domains.js.map +1 -1
- package/dist/GraphCache.d.ts +70 -0
- package/dist/GraphCache.d.ts.map +1 -0
- package/dist/GraphCache.js +111 -0
- package/dist/GraphCache.js.map +1 -0
- package/dist/GraphQuery.d.ts +67 -0
- package/dist/GraphQuery.d.ts.map +1 -0
- package/dist/GraphQuery.js +212 -0
- package/dist/GraphQuery.js.map +1 -0
- package/dist/GraphRender.d.ts +26 -0
- package/dist/GraphRender.d.ts.map +1 -0
- package/dist/GraphRender.js +154 -0
- package/dist/GraphRender.js.map +1 -0
- package/dist/GraphRules.d.ts +86 -0
- package/dist/GraphRules.d.ts.map +1 -0
- package/dist/GraphRules.js +246 -0
- package/dist/GraphRules.js.map +1 -0
- package/dist/GraphWorklist.d.ts +64 -0
- package/dist/GraphWorklist.d.ts.map +1 -0
- package/dist/GraphWorklist.js +283 -0
- package/dist/GraphWorklist.js.map +1 -0
- package/dist/Pack.d.ts +7 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +9 -5
- package/dist/Pack.js.map +1 -1
- package/dist/Survey.d.ts +112 -3
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +644 -41
- package/dist/Survey.js.map +1 -1
- package/package.json +7 -2
- package/src/Domains.ts +7 -4
- package/src/GraphCache.ts +191 -0
- package/src/GraphQuery.ts +315 -0
- package/src/GraphRender.ts +208 -0
- package/src/GraphRules.ts +293 -0
- package/src/GraphWorklist.ts +405 -0
- package/src/Pack.ts +15 -5
- package/src/Survey.ts +846 -52
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema"
|
|
2
|
+
import type { JsonSchema } from "@llm4ts/core/Models"
|
|
3
|
+
import type { GraphRules } from "./GraphRules.ts"
|
|
4
|
+
import {
|
|
5
|
+
Fill,
|
|
6
|
+
GraphEvidence,
|
|
7
|
+
SurveyEdge,
|
|
8
|
+
SurveyGraph,
|
|
9
|
+
applyFillsAndJoins,
|
|
10
|
+
nodeAttrs,
|
|
11
|
+
nodeId,
|
|
12
|
+
nodeKind,
|
|
13
|
+
nodeLabel,
|
|
14
|
+
nodeLineStart,
|
|
15
|
+
type SurveyNode,
|
|
16
|
+
type Unresolved
|
|
17
|
+
} from "./Survey.ts"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The bounded LLM pass of the code graph (ADR 0030 decision 4). The scanner's
|
|
21
|
+
* `unresolved` list becomes a worklist; the seat may fill an attr of an
|
|
22
|
+
* existing node or add an edge between two existing nodes, each tied to a
|
|
23
|
+
* source line that is checked here before anything is merged. It may not
|
|
24
|
+
* create nodes.
|
|
25
|
+
*/
|
|
26
|
+
export interface WorklistCandidate {
|
|
27
|
+
readonly id: string
|
|
28
|
+
readonly label: string
|
|
29
|
+
readonly attrs: Readonly<Record<string, string>>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorklistItem {
|
|
33
|
+
readonly unresolved: Unresolved
|
|
34
|
+
readonly node: SurveyNode
|
|
35
|
+
/** Fifteen lines either side of the item's line, numbered. */
|
|
36
|
+
readonly context: string
|
|
37
|
+
readonly candidates: ReadonlyArray<WorklistCandidate>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const contextWindow = 15
|
|
41
|
+
const candidateLimit = 8
|
|
42
|
+
|
|
43
|
+
const tokens = (text: string): ReadonlyArray<string> => [
|
|
44
|
+
...new Set(
|
|
45
|
+
text
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.split(/[^a-z0-9]+/)
|
|
48
|
+
.filter((token) => token.length >= 3)
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const numbered = (text: string, line: number): string =>
|
|
53
|
+
text
|
|
54
|
+
.split(/\r?\n/)
|
|
55
|
+
.map((content, index) => ({ number: index + 1, content }))
|
|
56
|
+
.filter(({ number }) => Math.abs(number - line) <= contextWindow)
|
|
57
|
+
.map(({ number, content }) => `${String(number).padStart(4, " ")} ${content}`)
|
|
58
|
+
.join("\n")
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Up to eight existing nodes worth naming to the seat: lexical matches on the
|
|
62
|
+
* item's reference, name and attrs, and for a join hole every node of the
|
|
63
|
+
* join's target kind (a dynamic URL matches no token of any mapping).
|
|
64
|
+
*/
|
|
65
|
+
const candidatesFor = (
|
|
66
|
+
graph: SurveyGraph,
|
|
67
|
+
item: Unresolved,
|
|
68
|
+
node: SurveyNode,
|
|
69
|
+
rules: GraphRules | undefined
|
|
70
|
+
): ReadonlyArray<WorklistCandidate> => {
|
|
71
|
+
const needles = tokens(
|
|
72
|
+
`${item.reference ?? ""} ${node.name} ${Object.values(nodeAttrs(node)).join(" ")}`
|
|
73
|
+
)
|
|
74
|
+
const targetKinds = new Set(
|
|
75
|
+
item.reason === "missing-attr" || item.reason === "join-from"
|
|
76
|
+
? (rules?.joins ?? []).filter((rule) => rule.kind === item.rule).map((rule) => rule.toKind)
|
|
77
|
+
: []
|
|
78
|
+
)
|
|
79
|
+
return graph.nodes
|
|
80
|
+
.filter((candidate) => nodeId(candidate) !== nodeId(node))
|
|
81
|
+
.map((candidate) => {
|
|
82
|
+
const hay =
|
|
83
|
+
`${nodeId(candidate)} ${nodeLabel(candidate)} ${Object.values(nodeAttrs(candidate)).join(" ")}`.toLowerCase()
|
|
84
|
+
const lexical = needles.filter((needle) => hay.includes(needle)).length
|
|
85
|
+
return { candidate, score: lexical + (targetKinds.has(nodeKind(candidate)) ? 1 : 0) }
|
|
86
|
+
})
|
|
87
|
+
.filter(({ score }) => score > 0)
|
|
88
|
+
.sort(
|
|
89
|
+
(left, right) =>
|
|
90
|
+
right.score - left.score || nodeId(left.candidate).localeCompare(nodeId(right.candidate))
|
|
91
|
+
)
|
|
92
|
+
.slice(0, candidateLimit)
|
|
93
|
+
.map(({ candidate }) => ({
|
|
94
|
+
id: nodeId(candidate),
|
|
95
|
+
label: nodeLabel(candidate),
|
|
96
|
+
attrs: nodeAttrs(candidate)
|
|
97
|
+
}))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Unresolved items, highest-degree node first (hubs resolve first), capped at `max`. */
|
|
101
|
+
export const worklistOf = (
|
|
102
|
+
graph: SurveyGraph,
|
|
103
|
+
contents: (path: string) => string | undefined,
|
|
104
|
+
max: number,
|
|
105
|
+
rules?: GraphRules
|
|
106
|
+
): ReadonlyArray<WorklistItem> => {
|
|
107
|
+
const degree = (id: string): number => graph.incoming(id).length + graph.outgoing(id).length
|
|
108
|
+
// Rank and cut first; reading context and scoring candidates is paid only
|
|
109
|
+
// for the items actually offered.
|
|
110
|
+
const ranked = graph.unresolved
|
|
111
|
+
.flatMap((item) => {
|
|
112
|
+
const node = graph.node(item.node)
|
|
113
|
+
return node === undefined ? [] : [{ item, node, degree: degree(item.node) }]
|
|
114
|
+
})
|
|
115
|
+
.sort(
|
|
116
|
+
(left, right) => right.degree - left.degree || left.item.node.localeCompare(right.item.node)
|
|
117
|
+
)
|
|
118
|
+
.slice(0, max)
|
|
119
|
+
return ranked.map(({ item, node }) => ({
|
|
120
|
+
unresolved: item,
|
|
121
|
+
node,
|
|
122
|
+
context: numbered(contents(item.file) ?? "", item.line),
|
|
123
|
+
candidates: candidatesFor(graph, item, node, rules)
|
|
124
|
+
}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const worklistBatches = <A>(
|
|
128
|
+
items: ReadonlyArray<A>,
|
|
129
|
+
size: number
|
|
130
|
+
): ReadonlyArray<ReadonlyArray<A>> => {
|
|
131
|
+
const step = Math.max(1, Math.floor(size))
|
|
132
|
+
const batches: Array<ReadonlyArray<A>> = []
|
|
133
|
+
for (let index = 0; index < items.length; index += step) {
|
|
134
|
+
batches.push(items.slice(index, index + step))
|
|
135
|
+
}
|
|
136
|
+
return batches
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const describeCandidate = (candidate: WorklistCandidate): string =>
|
|
140
|
+
Object.keys(candidate.attrs).length === 0
|
|
141
|
+
? candidate.id
|
|
142
|
+
: `${candidate.id} ${JSON.stringify(candidate.attrs)}`
|
|
143
|
+
|
|
144
|
+
const describeItem = (item: WorklistItem, index: number): string =>
|
|
145
|
+
[
|
|
146
|
+
`### Item ${index + 1}: ${item.unresolved.reason} — rule '${item.unresolved.rule}'`,
|
|
147
|
+
`Node: ${nodeId(item.node)} (line ${nodeLineStart(item.node)} of ${item.node.path})`,
|
|
148
|
+
...(item.unresolved.reference === undefined
|
|
149
|
+
? []
|
|
150
|
+
: [`Reference the scanner could not resolve: ${item.unresolved.reference}`]),
|
|
151
|
+
`Candidates (existing nodes with matching tokens): ${
|
|
152
|
+
item.candidates.length === 0 ? "none" : item.candidates.map(describeCandidate).join("; ")
|
|
153
|
+
}`,
|
|
154
|
+
"Source context:",
|
|
155
|
+
"```",
|
|
156
|
+
item.context,
|
|
157
|
+
"```"
|
|
158
|
+
].join("\n")
|
|
159
|
+
|
|
160
|
+
const defaultGuidance = [
|
|
161
|
+
"Regexes miss links the source establishes indirectly: a URL assembled from a variable or",
|
|
162
|
+
"configuration entry, a target held in a field, wiring declared in a descriptor, a fragment",
|
|
163
|
+
"pulled in by templating."
|
|
164
|
+
].join("\n")
|
|
165
|
+
|
|
166
|
+
export const worklistPrompt = (
|
|
167
|
+
batch: ReadonlyArray<WorklistItem>,
|
|
168
|
+
guidance: string | undefined
|
|
169
|
+
): string =>
|
|
170
|
+
[
|
|
171
|
+
"You are resolving holes in the dependency graph of a legacy estate. The graph was built",
|
|
172
|
+
"deterministically from pack rules; each item below is something the scanner could not",
|
|
173
|
+
"resolve. You have read-only access to the estate.",
|
|
174
|
+
guidance ?? defaultGuidance,
|
|
175
|
+
"",
|
|
176
|
+
"For each item, either resolve it or leave it. You may:",
|
|
177
|
+
'- fill a missing attribute of an EXISTING node ("attrs": node id, key, value) — e.g. the URL an',
|
|
178
|
+
" ajax call actually hits once its prefix variable is known;",
|
|
179
|
+
'- add an edge between two EXISTING nodes ("edges": from id, to id, a short kebab-case kind).',
|
|
180
|
+
"Every entry carries evidence: the file, the 1-based line, and the exact statement on that line.",
|
|
181
|
+
"No evidence, no entry. Use node ids exactly as given; never invent a node, a file or a line.",
|
|
182
|
+
'External systems, platform services and third-party libraries are not nodes; put them in "notes".',
|
|
183
|
+
"",
|
|
184
|
+
...batch.map(describeItem),
|
|
185
|
+
"",
|
|
186
|
+
'Respond only with JSON: {"edges":[{"from","to","kind","evidence":{"file","line","snippet"}}],',
|
|
187
|
+
'"attrs":[{"node","key","value","evidence":{"file","line","snippet"}}],"notes":[…]}'
|
|
188
|
+
].join("\n")
|
|
189
|
+
|
|
190
|
+
export class WorklistEdge extends Schema.Class<WorklistEdge>("WorklistEdge")({
|
|
191
|
+
from: Schema.String,
|
|
192
|
+
to: Schema.String,
|
|
193
|
+
kind: Schema.String,
|
|
194
|
+
evidence: GraphEvidence
|
|
195
|
+
}) {}
|
|
196
|
+
|
|
197
|
+
export class WorklistAttr extends Schema.Class<WorklistAttr>("WorklistAttr")({
|
|
198
|
+
node: Schema.String,
|
|
199
|
+
key: Schema.String,
|
|
200
|
+
value: Schema.String,
|
|
201
|
+
evidence: GraphEvidence
|
|
202
|
+
}) {}
|
|
203
|
+
|
|
204
|
+
export class WorklistAnswer extends Schema.Class<WorklistAnswer>("WorklistAnswer")({
|
|
205
|
+
edges: Schema.Array(WorklistEdge),
|
|
206
|
+
attrs: Schema.Array(WorklistAttr),
|
|
207
|
+
notes: Schema.Array(Schema.String)
|
|
208
|
+
}) {}
|
|
209
|
+
|
|
210
|
+
const evidenceJsonSchema: JsonSchema = {
|
|
211
|
+
type: "object",
|
|
212
|
+
properties: {
|
|
213
|
+
file: { type: "string" },
|
|
214
|
+
line: { type: "integer" },
|
|
215
|
+
snippet: { type: "string" }
|
|
216
|
+
},
|
|
217
|
+
required: ["file", "line", "snippet"]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const worklistAnswerJsonSchema: JsonSchema = {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
edges: {
|
|
224
|
+
type: "array",
|
|
225
|
+
items: {
|
|
226
|
+
type: "object",
|
|
227
|
+
properties: {
|
|
228
|
+
from: { type: "string" },
|
|
229
|
+
to: { type: "string" },
|
|
230
|
+
kind: { type: "string" },
|
|
231
|
+
evidence: evidenceJsonSchema
|
|
232
|
+
},
|
|
233
|
+
required: ["from", "to", "kind", "evidence"]
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
attrs: {
|
|
237
|
+
type: "array",
|
|
238
|
+
items: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
node: { type: "string" },
|
|
242
|
+
key: { type: "string" },
|
|
243
|
+
value: { type: "string" },
|
|
244
|
+
evidence: evidenceJsonSchema
|
|
245
|
+
},
|
|
246
|
+
required: ["node", "key", "value", "evidence"]
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
notes: { type: "array", items: { type: "string" } }
|
|
250
|
+
},
|
|
251
|
+
required: ["edges", "attrs", "notes"]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface VerifiedWorklist {
|
|
255
|
+
readonly edges: ReadonlyArray<SurveyEdge>
|
|
256
|
+
readonly fills: ReadonlyArray<Fill>
|
|
257
|
+
readonly dropped: ReadonlyArray<{ readonly what: string; readonly reason: string }>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The 1-based line within ±2 of `line` whose text contains the trimmed snippet, or undefined. */
|
|
261
|
+
const locateSnippet = (
|
|
262
|
+
text: string | undefined,
|
|
263
|
+
line: number,
|
|
264
|
+
snippet: string
|
|
265
|
+
): number | undefined => {
|
|
266
|
+
if (text === undefined) {
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
const needle = snippet.trim()
|
|
270
|
+
if (needle.length === 0) {
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
const lines = text.split(/\r?\n/)
|
|
274
|
+
for (const delta of [0, -1, 1, -2, 2]) {
|
|
275
|
+
const candidate = line + delta
|
|
276
|
+
if (candidate >= 1 && (lines[candidate - 1] ?? "").includes(needle)) {
|
|
277
|
+
return candidate
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const verifyWorklistAnswer = (
|
|
284
|
+
answer: WorklistAnswer,
|
|
285
|
+
graph: SurveyGraph,
|
|
286
|
+
contents: (path: string) => string | undefined
|
|
287
|
+
): VerifiedWorklist => {
|
|
288
|
+
const ids = new Set(graph.nodes.map(nodeId))
|
|
289
|
+
const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}`))
|
|
290
|
+
const edges: Array<SurveyEdge> = []
|
|
291
|
+
const fills: Array<Fill> = []
|
|
292
|
+
const dropped: Array<{ what: string; reason: string }> = []
|
|
293
|
+
const verified = (evidence: GraphEvidence): GraphEvidence | string => {
|
|
294
|
+
const line = locateSnippet(contents(evidence.file), evidence.line, evidence.snippet)
|
|
295
|
+
return line === undefined
|
|
296
|
+
? `snippet not found within two lines of line ${evidence.line}`
|
|
297
|
+
: GraphEvidence.make({ file: evidence.file, line, snippet: evidence.snippet.trim() })
|
|
298
|
+
}
|
|
299
|
+
for (const edge of answer.edges) {
|
|
300
|
+
const what = `${edge.from} -> ${edge.to} (${edge.kind})`
|
|
301
|
+
const unknown = [edge.from, edge.to].find((id) => !ids.has(id))
|
|
302
|
+
if (unknown !== undefined) {
|
|
303
|
+
dropped.push({ what, reason: `unknown endpoint: ${unknown}` })
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (edge.from === edge.to) {
|
|
307
|
+
dropped.push({ what, reason: "self loop" })
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
if (existing.has(`${edge.from}\u0000${edge.to}`)) {
|
|
311
|
+
dropped.push({ what, reason: "duplicate of an existing edge" })
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
const evidence = verified(edge.evidence)
|
|
315
|
+
if (typeof evidence === "string") {
|
|
316
|
+
dropped.push({ what, reason: evidence })
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
existing.add(`${edge.from}\u0000${edge.to}`)
|
|
320
|
+
edges.push(
|
|
321
|
+
SurveyEdge.make({
|
|
322
|
+
from: edge.from,
|
|
323
|
+
to: edge.to,
|
|
324
|
+
kind: edge.kind.replace(/^llm-/, ""),
|
|
325
|
+
origin: "llm",
|
|
326
|
+
confidence: "inferred",
|
|
327
|
+
rule: "llm",
|
|
328
|
+
mechanism: "llm",
|
|
329
|
+
evidence
|
|
330
|
+
})
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
const nodesById = new Map(graph.nodes.map((node) => [nodeId(node), node]))
|
|
334
|
+
for (const attr of answer.attrs) {
|
|
335
|
+
const what = `${attr.node}.${attr.key} = ${attr.value}`
|
|
336
|
+
const node = nodesById.get(attr.node)
|
|
337
|
+
if (node === undefined) {
|
|
338
|
+
dropped.push({ what, reason: `unknown node: ${attr.node}` })
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
// The seat fills holes; it never overrides what the scanner read.
|
|
342
|
+
if (nodeAttrs(node)[attr.key] !== undefined) {
|
|
343
|
+
dropped.push({ what, reason: `attr '${attr.key}' already set by the scanner` })
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
const evidence = verified(attr.evidence)
|
|
347
|
+
if (typeof evidence === "string") {
|
|
348
|
+
dropped.push({ what, reason: evidence })
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
fills.push(Fill.make({ node: attr.node, key: attr.key, value: attr.value, evidence }))
|
|
352
|
+
}
|
|
353
|
+
return { edges, fills, dropped }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const mergeWorklist = (
|
|
357
|
+
graph: SurveyGraph,
|
|
358
|
+
verified: VerifiedWorklist,
|
|
359
|
+
rules: GraphRules
|
|
360
|
+
): SurveyGraph =>
|
|
361
|
+
applyFillsAndJoins(
|
|
362
|
+
SurveyGraph.make({
|
|
363
|
+
nodes: graph.nodes,
|
|
364
|
+
edges: [...graph.edges, ...verified.edges],
|
|
365
|
+
unresolved: graph.unresolved,
|
|
366
|
+
fills: []
|
|
367
|
+
}),
|
|
368
|
+
[...graph.fills, ...verified.fills],
|
|
369
|
+
rules
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
const cell = (text: string): string => text.split(/\r?\n/).join(" ").replaceAll("|", "\\|")
|
|
373
|
+
|
|
374
|
+
export const renderWorklistReport = (
|
|
375
|
+
items: ReadonlyArray<WorklistItem>,
|
|
376
|
+
verified: VerifiedWorklist,
|
|
377
|
+
notes: ReadonlyArray<string>
|
|
378
|
+
): string =>
|
|
379
|
+
[
|
|
380
|
+
"# Graph refine (LLM worklist)",
|
|
381
|
+
"",
|
|
382
|
+
`${items.length} unresolved item(s) offered; ${verified.edges.length} edge(s) and ${verified.fills.length} attr fill(s) accepted; ${verified.dropped.length} dropped.`,
|
|
383
|
+
"Accepted entries carry the source line that establishes them and are re-verified on every rebuild.",
|
|
384
|
+
"",
|
|
385
|
+
"## Accepted",
|
|
386
|
+
"",
|
|
387
|
+
"| What | Evidence |",
|
|
388
|
+
"| ---- | -------- |",
|
|
389
|
+
...verified.edges.map(
|
|
390
|
+
(edge) =>
|
|
391
|
+
`| ${cell(`${edge.from} → ${edge.to} (${edge.kind})`)} | ${cell(`${edge.evidence?.file}:${edge.evidence?.line} ${edge.evidence?.snippet ?? ""}`)} |`
|
|
392
|
+
),
|
|
393
|
+
...verified.fills.map(
|
|
394
|
+
(fill) =>
|
|
395
|
+
`| ${cell(`${fill.node}.${fill.key} = ${fill.value}`)} | ${cell(`${fill.evidence.file}:${fill.evidence.line} ${fill.evidence.snippet}`)} |`
|
|
396
|
+
),
|
|
397
|
+
"",
|
|
398
|
+
"## Dropped",
|
|
399
|
+
"",
|
|
400
|
+
"| What | Reason |",
|
|
401
|
+
"| ---- | ------ |",
|
|
402
|
+
...verified.dropped.map((drop) => `| ${cell(drop.what)} | ${cell(drop.reason)} |`),
|
|
403
|
+
"",
|
|
404
|
+
...(notes.length === 0 ? [] : ["## Notes", "", ...notes.map((note) => `- ${cell(note)}`), ""])
|
|
405
|
+
].join("\n")
|
package/src/Pack.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"
|
|
|
4
4
|
import { Dimension } from "@llm4ts/core/eval/Eval"
|
|
5
5
|
import type { ConsolidateRules } from "./Domains.ts"
|
|
6
6
|
import { PlanParseError } from "./FlowError.ts"
|
|
7
|
+
import { edgeKindsOf, parseGraphRules, type GraphRules } from "./GraphRules.ts"
|
|
7
8
|
import { oracleRulesFrom, type OracleRules } from "./OracleGuard.ts"
|
|
8
9
|
import { Reviewer, parseReviewer } from "./Reviewer.ts"
|
|
9
10
|
import { CoverageRule } from "./SpecChecks.ts"
|
|
@@ -37,6 +38,12 @@ export interface Pack {
|
|
|
37
38
|
readonly judgeDimensions: ReadonlyArray<Dimension>
|
|
38
39
|
readonly coverage: ReadonlyArray<CoverageRule>
|
|
39
40
|
readonly survey: ReadonlyArray<CoverageRule>
|
|
41
|
+
/**
|
|
42
|
+
* The graph vocabulary (ADR 0030): `## Node:`, `## Edge:` (with every
|
|
43
|
+
* `## Survey:` folded in as a file-to-file edge), `## Join:`, `## Probe:`
|
|
44
|
+
* and the `## Graph` budgets.
|
|
45
|
+
*/
|
|
46
|
+
readonly graph: GraphRules
|
|
40
47
|
readonly prompts: Readonly<Record<string, string>>
|
|
41
48
|
readonly lenses: ReadonlyArray<Reviewer>
|
|
42
49
|
readonly lessons: string | undefined
|
|
@@ -391,16 +398,18 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
391
398
|
cluster: commaList(consolidateValues.cluster),
|
|
392
399
|
context: commaList(consolidateValues.context)
|
|
393
400
|
}
|
|
401
|
+
const survey = rules(manifest.sections, "## Survey: ")
|
|
402
|
+
const graph = yield* parseGraphRules(manifest.sections, survey)
|
|
394
403
|
if (consolidate !== undefined) {
|
|
395
|
-
const
|
|
404
|
+
const knownKinds = new Set([...survey.map((rule) => rule.name), ...edgeKindsOf(graph)])
|
|
396
405
|
const unknown = [...consolidate.cluster, ...consolidate.context].filter(
|
|
397
|
-
(kind) => !
|
|
406
|
+
(kind) => !knownKinds.has(kind) && !kind.startsWith("llm-") && !kind.endsWith("*")
|
|
398
407
|
)
|
|
399
408
|
if (unknown.length > 0) {
|
|
400
409
|
return yield* PlanParseError.make({
|
|
401
410
|
message:
|
|
402
|
-
`pack manifest '## Consolidate' names edge kinds no '## Survey:' rule produces: ${unknown.join(", ")} ` +
|
|
403
|
-
`(known: ${[...
|
|
411
|
+
`pack manifest '## Consolidate' names edge kinds no '## Survey:', '## Edge:' or '## Join:' rule produces: ${unknown.join(", ")} ` +
|
|
412
|
+
`(known: ${[...knownKinds].join(", ") || "none"}; 'llm-*' matches refined edges)`
|
|
404
413
|
})
|
|
405
414
|
}
|
|
406
415
|
const both = consolidate.cluster.filter((kind) => consolidate.context.includes(kind))
|
|
@@ -438,7 +447,8 @@ export const loadPack = Effect.fn("@llm4ts/flow/Pack.load")(function* (
|
|
|
438
447
|
}),
|
|
439
448
|
judgeDimensions: dimensions(section(manifest.sections, "Judge")),
|
|
440
449
|
coverage: rules(manifest.sections, "## Coverage: "),
|
|
441
|
-
survey
|
|
450
|
+
survey,
|
|
451
|
+
graph,
|
|
442
452
|
prompts,
|
|
443
453
|
lenses,
|
|
444
454
|
lessons: lessons === undefined || lessons.length === 0 ? undefined : lessons,
|