@deepblend/dsh-blender-contracts 0.1.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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The visual iteration controller (SPEC §12.1 PREVIEW → VISUAL_REVIEW → PATCH loop).
3
+ *
4
+ * WHAT THIS FILE IS ALLOWED TO KNOW
5
+ * ---------------------------------
6
+ * It knows nothing about Blender, files, Cordis or HTTP. Everything it does happens
7
+ * through four injected ports:
8
+ *
9
+ * review() measure a revision and produce a scored VisualReview
10
+ * patch() commit a ScenePatch and return the new revision
11
+ * restore() move the current pointer back
12
+ * reviewer() ask the vision model what it sees, and what it would change
13
+ *
14
+ * That boundary is not decoration. It is what lets the loop's SEMANTICS — the
15
+ * iteration cap, the repeated-issue stop, "only adopt a change that improves the
16
+ * score", and the handover package — be tested deterministically against a stub,
17
+ * while "the model really sees the image" is tested once, live. Mixing those two
18
+ * claims into one untestable blob is how a project ends up asserting only one of
19
+ * them (decision D34).
20
+ *
21
+ * WHY A REJECTED FIX IS ROLLED BACK RATHER THAN LEFT IN PLACE
22
+ * ----------------------------------------------------------
23
+ * The revision history is append-only (M1), so the rejected revision stays on disk
24
+ * and stays in the history — that is the audit trail. But the CURRENT POINTER is a
25
+ * statement about which revision is the deliverable, and moving it to a revision
26
+ * that measured worse would make the project worse while claiming progress. So: keep
27
+ * the history, decline the pointer. The rollback is itself recorded, in the round
28
+ * record and in the run document.
29
+ *
30
+ * WHICH PACKAGE THIS LIVES IN
31
+ * --------------------------
32
+ * `contracts`, beside the scorer and the sheet compositor, and not in the host
33
+ * package that calls it. "Contracts" here means the project's own vocabulary and its
34
+ * pure rules — `visual-issue.js` already holds the scoring rules by that reading — and
35
+ * the loop controller is the rest of them: what may be adopted, when to stop, what a
36
+ * handover contains. It imports nothing outside this package, which is what lets the
37
+ * acceptance suite exercise it without a Blender, a Cordis context or a model.
38
+ *
39
+ * Owner: DeepBlend Studio — M2
40
+ */
41
+
42
+ import {
43
+ VISUAL_PASS_SCORE,
44
+ seedFingerprintCounters,
45
+ shouldHandOver,
46
+ updateFingerprintCounters,
47
+ validateFindings,
48
+ } from './visual-issue.js'
49
+
50
+ /**
51
+ * @typedef {object} VisualReviewRound
52
+ * @property {number} round - 1-based.
53
+ * @property {string} revision - the revision reviewed in this round.
54
+ * @property {number} score
55
+ * @property {number} issueCount
56
+ * @property {string[]} issueCodes
57
+ * @property {object[]} reported - findings the vision model authored and that passed validation.
58
+ * @property {object[]} rejectedFindings - findings the model authored that did not.
59
+ * @property {string} outcome - one of `baseline`, `applied`, `rejected`, `stopped`, `handover`.
60
+ * @property {string|null} reason
61
+ * @property {string|null} newRevision
62
+ * @property {object|null} appliedPatch
63
+ * @property {string} [rolledBackTo] - set on a round whose revision was committed and
64
+ * then declined: the revision stays in the history, the current pointer went back.
65
+ */
66
+
67
+ /**
68
+ * Run the visual loop.
69
+ *
70
+ * @param {object} input
71
+ * @param {string} input.projectId
72
+ * @param {string} input.revision - the starting (current) revision.
73
+ * @param {(request: object) => Promise<object>} input.review
74
+ * `({ projectId, revision, iteration, signal }) => VisualReview` — measures and scores.
75
+ * @param {(request: object) => Promise<object>} input.patch
76
+ * `({ projectId, baseRevision, operations, note, actor, stage, idempotencyKey, saveCheckpoint, signal }) => RevisionSummary`
77
+ * @param {(request: object) => Promise<object>} input.restore
78
+ * `({ projectId, revision, reason }) => unknown`
79
+ * @param {(request: object) => Promise<{ findings: object[], operations: object[], note: string|null, detail: object|null }>} input.reviewer
80
+ * The vision port. Receives `{ review, round, previousRounds, signal }`.
81
+ * @param {(line: string) => void} [input.log]
82
+ * @param {number} [input.maxIterations]
83
+ * @param {number} [input.stopOnRepeatedIssueCount]
84
+ * @param {number} [input.minConfidenceForAutoFix]
85
+ * @param {AbortSignal} [input.signal]
86
+ * @returns {Promise<object>} the run document: rounds, final score, open issues and
87
+ * a handover package whenever the loop stopped short of passing.
88
+ */
89
+ export async function runVisualLoop(input) {
90
+ const maxIterations = positiveInteger(input.maxIterations, 5)
91
+ const stopOnRepeatedIssueCount = positiveInteger(input.stopOnRepeatedIssueCount, 2)
92
+ const minConfidence = numberInRange(input.minConfidenceForAutoFix, 0.8)
93
+ const log = typeof input.log === 'function' ? input.log : () => {}
94
+
95
+ /** @type {VisualReviewRound[]} */
96
+ const rounds = []
97
+ /** @type {Map<string, number>} */
98
+ const fingerprints = new Map()
99
+
100
+ let currentRevision = input.revision
101
+ let iteration = 0
102
+ let handedOver = null
103
+
104
+ /** The latest measured state. Replaced (never mutated) when a round is adopted. */
105
+ let currentReview = await input.review({
106
+ projectId: input.projectId,
107
+ revision: currentRevision,
108
+ iteration,
109
+ signal: input.signal,
110
+ })
111
+ let score = currentReview.score
112
+ // The baseline is an OBSERVATION, not an attempt, so it seeds the counters at zero
113
+ // rather than one. Counting it would make "the same issue recurred twice" true
114
+ // after a single failed fix — and the loop would then stop before trying the second
115
+ // thing it thought of, which is the opposite of what SPEC §12.3 asks for. The rule
116
+ // is about a problem that survives REPEATED FIX ATTEMPTS.
117
+ seedFingerprintCounters(fingerprints, currentReview.issues)
118
+ rounds.push({
119
+ round: 0,
120
+ revision: currentRevision,
121
+ score,
122
+ issueCount: currentReview.issues.length,
123
+ issueCodes: currentReview.issues.map(issue => issue.code),
124
+ reported: [],
125
+ rejectedFindings: [],
126
+ outcome: 'baseline',
127
+ reason: null,
128
+ newRevision: null,
129
+ appliedPatch: null,
130
+ })
131
+ log(`baseline ${currentRevision}: score ${score}, ${currentReview.issues.length} measured issue(s)`)
132
+
133
+ while (true) {
134
+ const decision = shouldHandOver({
135
+ score,
136
+ iteration,
137
+ maxIterations,
138
+ fingerprints,
139
+ stopOnRepeatedIssueCount,
140
+ })
141
+ if (decision.stop) {
142
+ handedOver = {
143
+ reason: decision.reason,
144
+ fingerprint: decision.fingerprint ?? null,
145
+ iteration,
146
+ score,
147
+ revision: currentRevision,
148
+ }
149
+ log(`stopping after ${iteration} iteration(s): ${decision.reason}`)
150
+ break
151
+ }
152
+
153
+ iteration += 1
154
+ const vision = await input.reviewer({
155
+ review: currentReview,
156
+ round: iteration,
157
+ previousRounds: rounds.filter(round => round.outcome === 'applied' || round.outcome === 'rejected'),
158
+ signal: input.signal,
159
+ })
160
+
161
+ // The model's findings are validated against the review it was shown, and the
162
+ // ones that survive are kept as the round's `reported` list. They are NOT added
163
+ // to the review's `issues`: the score must stay a function of measurements
164
+ // (D30), so a finding only the model can see is evidence for a human, never for
165
+ // the scorer.
166
+ const context = {
167
+ viewIds: new Set(currentReview.perView.map(entry => entry.viewId)),
168
+ objectIds: new Set(objectIdsOf(currentReview)),
169
+ }
170
+ const validated = validateFindings(vision.findings, context)
171
+
172
+ const candidates = (vision.operations ?? []).filter(operation => {
173
+ const confidence = typeof operation?.confidence === 'number' ? operation.confidence : 1
174
+ return confidence >= minConfidence
175
+ })
176
+
177
+ /** @type {VisualReviewRound} */
178
+ const round = {
179
+ round: iteration,
180
+ revision: currentRevision,
181
+ score,
182
+ issueCount: currentReview.issues.length,
183
+ issueCodes: currentReview.issues.map(issue => issue.code),
184
+ reported: validated.accepted,
185
+ rejectedFindings: validated.rejected,
186
+ outcome: 'stopped',
187
+ reason: null,
188
+ newRevision: null,
189
+ appliedPatch: null,
190
+ }
191
+
192
+ if (candidates.length === 0) {
193
+ round.reason = (vision.operations ?? []).length > 0 ? 'NO_CONFIDENT_FIX' : 'NO_FIX_PROPOSED'
194
+ rounds.push(round)
195
+ handedOver = { reason: round.reason, fingerprint: null, iteration, score, revision: currentRevision }
196
+ log(`round ${iteration}: the reviewer proposed no usable change (${round.reason})`)
197
+ break
198
+ }
199
+
200
+ const operations = candidates.flatMap(operation => normaliseOperation(operation))
201
+ if (operations.length === 0) {
202
+ round.reason = 'NO_FIX_PROPOSED'
203
+ rounds.push(round)
204
+ handedOver = { reason: round.reason, fingerprint: null, iteration, score, revision: currentRevision }
205
+ break
206
+ }
207
+
208
+ let summary
209
+ try {
210
+ summary = await input.patch({
211
+ projectId: input.projectId,
212
+ baseRevision: currentRevision,
213
+ operations,
214
+ note: vision.note ?? `visual review round ${iteration}`,
215
+ actor: 'deepblend.visual-loop',
216
+ stage: 'VISUAL_REVIEW',
217
+ // A deterministic key: the same operations against the same base revision
218
+ // are the same intent, so a retry after a crash replays instead of
219
+ // committing twice (M1's idempotency semantics, for free).
220
+ idempotencyKey: `visual-${input.projectId}-${currentRevision}-${iteration}`,
221
+ saveCheckpoint: true,
222
+ signal: input.signal,
223
+ })
224
+ } catch (cause) {
225
+ // A refused patch is not a crash: it is the reviewer having proposed
226
+ // something the scene will not accept, which is information. Record it and
227
+ // keep going — the next round sees the failure in `previousRounds`.
228
+ round.outcome = 'rejected'
229
+ round.reason = `PATCH_REFUSED: ${cause instanceof Error ? cause.message : String(cause)}`
230
+ rounds.push(round)
231
+ // ...but the measured state is unchanged, so the same findings are present
232
+ // again and this round has to count towards the repeated-issue rule. Without
233
+ // this, a scene that refuses every proposal would run to the iteration cap
234
+ // instead of recognising what is happening after the second refusal.
235
+ updateFingerprintCounters(fingerprints, currentReview.issues)
236
+ continue
237
+ }
238
+
239
+ round.appliedPatch = { operations, note: vision.note ?? null }
240
+ round.newRevision = summary.revision
241
+
242
+ const after = await input.review({
243
+ projectId: input.projectId,
244
+ revision: summary.revision,
245
+ iteration,
246
+ signal: input.signal,
247
+ })
248
+
249
+ if (after.score > score) {
250
+ const previousScore = score
251
+ score = after.score
252
+ currentRevision = summary.revision
253
+ currentReview = after
254
+ round.outcome = 'applied'
255
+ round.reason = `score ${previousScore} -> ${score}`
256
+ rounds.push(round)
257
+ updateFingerprintCounters(fingerprints, after.issues)
258
+ log(`round ${iteration}: applied ${operations.length} operation(s), score ${previousScore} -> ${score} on ${currentRevision}`)
259
+ continue
260
+ }
261
+
262
+ // Not an improvement. Keep the history, decline the pointer.
263
+ await input.restore({
264
+ projectId: input.projectId,
265
+ revision: round.revision,
266
+ reason: `visual round ${iteration} measured ${after.score}, not better than ${score}`,
267
+ })
268
+ round.outcome = 'rejected'
269
+ round.reason = `score did not improve (${score} -> ${after.score})`
270
+ round.rolledBackTo = round.revision
271
+ rounds.push(round)
272
+ // The measured state is unchanged (the pointer went back), so the same
273
+ // fingerprints are present again — which is exactly what makes a fix that
274
+ // cannot work stop the loop instead of repeating until the cap.
275
+ updateFingerprintCounters(fingerprints, currentReview.issues)
276
+ log(`round ${iteration}: rejected — score did not improve (${score} -> ${after.score}); pointer restored to ${currentRevision}`)
277
+ }
278
+
279
+ // A handover is something a human has to pick up, so a run that PASSED has none.
280
+ // Reporting one would tell a reader to take over work that is finished.
281
+ const handover = handedOver === null || handedOver.reason === 'PASSING_SCORE'
282
+ ? null
283
+ : buildHandover({ handedOver, openIssues: currentReview.issues, rounds, review: currentReview })
284
+
285
+ return {
286
+ projectId: input.projectId,
287
+ startRevision: input.revision,
288
+ finalRevision: currentRevision,
289
+ startScore: rounds[0].score,
290
+ finalScore: score,
291
+ passed: score >= VISUAL_PASS_SCORE,
292
+ iterations: iteration,
293
+ maxIterations,
294
+ stopReason: handedOver?.reason ?? 'PASSING_SCORE',
295
+ rounds,
296
+ openIssues: currentReview.issues,
297
+ sheet: currentReview.sheet ?? null,
298
+ handover,
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Every object id named anywhere in a review, measured or reported.
304
+ *
305
+ * The set is the validation context for the model's findings: an object the review
306
+ * never measured is still legitimate to talk about IF the review mentions it (a
307
+ * finding may name a second object as the thing in the way), so the context is
308
+ * everything the review knows rather than only the subject.
309
+ *
310
+ * @param {object} review
311
+ * @returns {string[]}
312
+ */
313
+ function objectIdsOf(review) {
314
+ const ids = new Set()
315
+ for (const issue of review.issues ?? []) {
316
+ if (issue.objectId) ids.add(issue.objectId)
317
+ }
318
+ return [...ids]
319
+ }
320
+
321
+ /**
322
+ * The package a human needs to take over (SPEC §20 M2 "失败可人工接管").
323
+ *
324
+ * Three things, and each one answers a different question a person asks when they
325
+ * open the project: WHERE is the work (a revision that is not polluted), WHAT is
326
+ * still wrong (the measured issues, with the numbers), and WHAT TO DO NEXT
327
+ * (concrete operations and views, not "adjust the lighting").
328
+ *
329
+ * @param {object} input
330
+ * @returns {object}
331
+ */
332
+ function buildHandover(input) {
333
+ const { handedOver, openIssues, rounds, review } = input
334
+ const investigatedViews = [...new Set(openIssues.map(issue => issue.viewId).filter(Boolean))]
335
+ const attempted = rounds.filter(round => round.appliedPatch !== null)
336
+
337
+ const suggestions = []
338
+ if (openIssues.length > 0) {
339
+ suggestions.push(
340
+ `Look at the ${openIssues.length} open issue(s) below with ` +
341
+ `${review.sheet?.path ?? 'the contact sheet'} open; the measurements name the view and the object.`,
342
+ )
343
+ }
344
+ for (const viewId of investigatedViews) suggestions.push(`Re-render view "${viewId}" alone at full resolution before editing.`)
345
+ if (attempted.length > 0) {
346
+ suggestions.push(
347
+ `The automated rounds tried ${attempted.length} change(s) and none improved the measured score; ` +
348
+ 'the review rounds below record exactly what each one was.',
349
+ )
350
+ }
351
+ if (handedOver.reason === 'REPEATED_ISSUE') {
352
+ suggestions.push(
353
+ `The same finding recurred without improving (${handedOver.fingerprint ?? 'see rounds'}), ` +
354
+ 'so the loop stopped rather than spending another render on it.',
355
+ )
356
+ }
357
+ suggestions.push(
358
+ 'Edit the scene with blender_scene_patch, or open the checkpoint in Blender and re-sync the scene summary afterwards.',
359
+ )
360
+
361
+ return {
362
+ reason: handedOver.reason,
363
+ iteration: handedOver.iteration,
364
+ score: handedOver.score,
365
+ /** The revision to work from: whatever the loop last accepted, current pointer included. */
366
+ revision: handedOver.revision,
367
+ /** Where the failing revision still lives, so a human can inspect what was tried. */
368
+ attemptedRevisions: attempted.map(round => round.newRevision).filter(revision => revision !== null),
369
+ openIssues,
370
+ views: investigatedViews,
371
+ suggestions,
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Coerce one proposed operation into a ScenePatch operation.
377
+ *
378
+ * The reviewer may hand back either a bare operation or one wrapped with a
379
+ * confidence score, because the prompt asks for the latter and models are
380
+ * inconsistent about honouring a shape. Accepting both is cheaper than a retry, and
381
+ * the wrapper is dropped here so nothing downstream has to know about it.
382
+ *
383
+ * @param {object} proposed
384
+ * @returns {object[]}
385
+ */
386
+ function normaliseOperation(proposed) {
387
+ if (proposed === null || typeof proposed !== 'object') return []
388
+ const operation = 'op' in proposed ? proposed : proposed.operation
389
+ if (operation === null || typeof operation !== 'object' || typeof operation.op !== 'string') return []
390
+ return [operation]
391
+ }
392
+
393
+ /** A positive integer option, with a default. */
394
+ function positiveInteger(value, fallback) {
395
+ return Number.isInteger(value) && value > 0 ? value : fallback
396
+ }
397
+
398
+ /** A number in [0, 1], with a default. */
399
+ function numberInRange(value, fallback) {
400
+ return typeof value === 'number' && value >= 0 && value <= 1 ? value : fallback
401
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@deepblend/dsh-blender-contracts",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./package.json": "./package.json"
12
+ },
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/pearjelly/deep-blend.git"
17
+ },
18
+ "dependencies": {}
19
+ }