@deepblend/dsh-blender-contracts 0.2.20 → 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.
@@ -12,8 +12,8 @@
12
12
  * reviewer() ask the vision model what it sees, and what it would change
13
13
  *
14
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,
15
+ * iteration cap, the repeated-issue stop, "only adopt evidenced improvements without
16
+ * regression", and the handover package — be tested deterministically against a stub,
17
17
  * while "the model really sees the image" is tested once, live. Mixing those two
18
18
  * claims into one untestable blob is how a project ends up asserting only one of
19
19
  * them (decision D34).
@@ -46,6 +46,8 @@ import {
46
46
  updateFingerprintCounters,
47
47
  validateFindings,
48
48
  } from './visual-issue.js'
49
+ import { validateArtisticReview, artisticRegressed } from './artistic-review.js'
50
+ import { reviewInputsDigest } from './scene-spec.js'
49
51
 
50
52
  /**
51
53
  * @typedef {object} VisualReviewRound
@@ -71,13 +73,15 @@ import {
71
73
  * @param {string} input.projectId
72
74
  * @param {string} input.revision - the starting (current) revision.
73
75
  * @param {(request: object) => Promise<object>} input.review
74
- * `({ projectId, revision, iteration, signal }) => VisualReview` — measures and scores.
76
+ * `({ projectId, revision, iteration, subjectId?, signal }) => VisualReview` — measures and scores.
77
+ * After the baseline, an own subjectId (including null) fixes what is measured.
75
78
  * @param {(request: object) => Promise<object>} input.patch
76
79
  * `({ projectId, baseRevision, operations, note, actor, stage, idempotencyKey, saveCheckpoint, signal }) => RevisionSummary`
77
80
  * @param {(request: object) => Promise<object>} input.restore
78
- * `({ projectId, revision, reason }) => unknown`
81
+ * `({ projectId, revision, expectedCurrentRevision, reason }) => unknown` — rejects a concurrent current-revision change.
79
82
  * @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 }`.
83
+ * The vision port. Receives `{ review, baselineReview?, round, previousRounds, signal }`.
84
+ * Returns artistic dimension assessments and, for a candidate, a comparison judgment.
81
85
  * @param {(line: string) => void} [input.log]
82
86
  * @param {number} [input.maxIterations]
83
87
  * @param {number} [input.stopOnRepeatedIssueCount]
@@ -87,7 +91,7 @@ import {
87
91
  * a handover package whenever the loop stopped short of passing.
88
92
  */
89
93
  export async function runVisualLoop(input) {
90
- const maxIterations = positiveInteger(input.maxIterations, 5)
94
+ const maxIterations = input.maxIterations === 0 ? 0 : positiveInteger(input.maxIterations, 5)
91
95
  const stopOnRepeatedIssueCount = positiveInteger(input.stopOnRepeatedIssueCount, 2)
92
96
  const minConfidence = numberInRange(input.minConfidenceForAutoFix, 0.8)
93
97
  const log = typeof input.log === 'function' ? input.log : () => {}
@@ -109,6 +113,16 @@ export async function runVisualLoop(input) {
109
113
  signal: input.signal,
110
114
  })
111
115
  let score = currentReview.score
116
+ const authoredInputsDigest = checkedReviewInputsDigest(currentReview)
117
+ // Legacy measurement-only ports omitted both subject identity and scene context.
118
+ // Real Host reviews carry these facts, so later rounds must prove the same subject.
119
+ const subjectFixed = hasSubjectEvidence(currentReview)
120
+ const fixedSubjectId = currentReview.subjectId ?? null
121
+ const subjectEvidence = { sceneContext: currentReview.sceneContext != null, subject: currentReview.subject != null }
122
+ const baselineSubjectError = subjectFixed ? reviewedSubjectIssue(currentReview, fixedSubjectId) : null
123
+ let artistic = validateArtisticReview(null, new Set(), minConfidence, {
124
+ subject: baselineSubjectError ? { available: false, reason: baselineSubjectError.message } : currentReview.subject,
125
+ })
112
126
  // The baseline is an OBSERVATION, not an attempt, so it seeds the counters at zero
113
127
  // rather than one. Counting it would make "the same issue recurred twice" true
114
128
  // after a single failed fix — and the loop would then stop before trying the second
@@ -124,15 +138,21 @@ export async function runVisualLoop(input) {
124
138
  reported: [],
125
139
  rejectedFindings: [],
126
140
  outcome: 'baseline',
127
- reason: null,
141
+ reason: baselineSubjectError?.message ?? null,
142
+ ...(subjectFixed ? { fixedSubjectId } : {}),
128
143
  newRevision: null,
129
144
  appliedPatch: null,
130
145
  })
131
146
  log(`baseline ${currentRevision}: score ${score}, ${currentReview.issues.length} measured issue(s)`)
132
147
 
133
148
  while (true) {
149
+ if (baselineSubjectError) {
150
+ handedOver = { reason: baselineSubjectError.reviewSubjectReason, iteration, score, revision: currentRevision }
151
+ break
152
+ }
134
153
  const decision = shouldHandOver({
135
154
  score,
155
+ artisticPassed: artistic.status === 'pass',
136
156
  iteration,
137
157
  maxIterations,
138
158
  fingerprints,
@@ -157,6 +177,8 @@ export async function runVisualLoop(input) {
157
177
  previousRounds: rounds.filter(round => round.outcome === 'applied' || round.outcome === 'rejected'),
158
178
  signal: input.signal,
159
179
  })
180
+ artistic = assessArtisticReview(vision.artistic, currentReview, minConfidence)
181
+ seedFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
160
182
 
161
183
  // The model's findings are validated against the review it was shown, and the
162
184
  // ones that survive are kept as the round's `reported` list. They are NOT added
@@ -187,6 +209,16 @@ export async function runVisualLoop(input) {
187
209
  reason: null,
188
210
  newRevision: null,
189
211
  appliedPatch: null,
212
+ artisticBefore: artistic,
213
+ reviewer: vision.detail ?? null,
214
+ ...(subjectFixed ? { fixedSubjectId } : {}),
215
+ }
216
+
217
+ if (score >= VISUAL_PASS_SCORE && artistic.status === 'pass') {
218
+ round.reason = 'PASSING_REVIEW'
219
+ rounds.push(round)
220
+ handedOver = { reason: 'PASSING_REVIEW', iteration, score, revision: currentRevision }
221
+ break
190
222
  }
191
223
 
192
224
  if (candidates.length === 0) {
@@ -205,6 +237,34 @@ export async function runVisualLoop(input) {
205
237
  break
206
238
  }
207
239
 
240
+ const subjectOperation = operations.find(operation => operation.op === 'project.reviewSubject.set' ||
241
+ (subjectFixed && fixedSubjectId !== null && operation.entityId === fixedSubjectId &&
242
+ (operation.op === 'entity.remove' || (operation.op === 'entity.visibility.set' && operation.visible === false))))
243
+ if (subjectOperation) {
244
+ round.outcome = 'rejected'
245
+ round.reason = `REVIEW_SUBJECT_OPERATION_REFUSED: ${subjectOperation.op}`
246
+ rounds.push(round)
247
+ handedOver = { reason: 'REVIEW_SUBJECT_OPERATION_REFUSED', iteration, score, revision: currentRevision }
248
+ break
249
+ }
250
+
251
+ // The model may improve the result, but cannot lower the authored target or
252
+ // replace the reference it is being judged against. Refuse the entire proposal.
253
+ const referenceAssets = new Set([
254
+ ...(currentReview.sceneContext?.project?.referenceImages ?? []),
255
+ ...(currentReview.referenceImages ?? []),
256
+ ].map(reference => reference.assetId))
257
+ const forbidden = operations.find(operation => operation.op === 'project.brief.set' ||
258
+ (operation.op === 'asset.remove' && referenceAssets.has(operation.assetId)) ||
259
+ (operation.op === 'asset.add' && referenceAssets.has(operation.asset?.id)))
260
+ if (forbidden) {
261
+ round.outcome = 'rejected'
262
+ round.reason = `REVIEW_INPUT_OPERATION_REFUSED: ${forbidden.op}`
263
+ rounds.push(round)
264
+ handedOver = { reason: 'REVIEW_INPUT_OPERATION_REFUSED', iteration, score, revision: currentRevision }
265
+ break
266
+ }
267
+
208
268
  let summary
209
269
  try {
210
270
  summary = await input.patch({
@@ -232,29 +292,71 @@ export async function runVisualLoop(input) {
232
292
  // again and this round has to count towards the repeated-issue rule. Without
233
293
  // this, a scene that refuses every proposal would run to the iteration cap
234
294
  // instead of recognising what is happening after the second refusal.
235
- updateFingerprintCounters(fingerprints, currentReview.issues)
295
+ updateFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
236
296
  continue
237
297
  }
238
298
 
239
299
  round.appliedPatch = { operations, note: vision.note ?? null }
240
300
  round.newRevision = summary.revision
241
301
 
242
- const after = await input.review({
243
- projectId: input.projectId,
244
- revision: summary.revision,
245
- iteration,
246
- signal: input.signal,
247
- })
302
+ let after, afterVision, afterArtistic
303
+ try {
304
+ after = await input.review({
305
+ projectId: input.projectId, revision: summary.revision, iteration, signal: input.signal,
306
+ ...(subjectFixed ? { subjectId: fixedSubjectId } : {}),
307
+ })
308
+ if (checkedReviewInputsDigest(after) !== authoredInputsDigest) {
309
+ throw reviewInputsChanged('The candidate changed the authored goal or visual reference inputs.')
310
+ }
311
+ if (subjectFixed) {
312
+ const issue = reviewedSubjectIssue(after, fixedSubjectId, subjectEvidence)
313
+ if (issue) throw issue
314
+ }
315
+ if (after.score >= score) {
316
+ afterVision = await input.reviewer({
317
+ review: after, baselineReview: currentReview, round: iteration,
318
+ previousRounds: rounds.filter(entry => entry.outcome === 'applied' || entry.outcome === 'rejected'),
319
+ signal: input.signal,
320
+ })
321
+ afterArtistic = assessArtisticReview(afterVision.artistic, after, minConfidence)
322
+ }
323
+ } catch (cause) {
324
+ await input.restore({ projectId: input.projectId, revision: currentRevision,
325
+ expectedCurrentRevision: summary.revision,
326
+ reason: 'candidate review failed; keep the previously reviewed revision' })
327
+ round.outcome = 'rejected'
328
+ const reason = cause?.reviewSubjectReason ?? (cause?.reviewInputsChanged ? 'REVIEW_INPUTS_CHANGED' : 'REVIEW_FAILED')
329
+ round.reason = `${reason}: ${cause instanceof Error ? cause.message : String(cause)}`
330
+ round.rolledBackTo = currentRevision
331
+ rounds.push(round)
332
+ handedOver = { reason, iteration, score, revision: currentRevision }
333
+ break
334
+ }
335
+ round.artisticAfter = afterArtistic ?? null
336
+ round.comparisonReviewer = afterVision?.detail ?? null
337
+ // Aggregate gains cannot hide a newly introduced major/critical defect.
338
+ const severity = { minor: 1, major: 2, critical: 3 }
339
+ const priorSeverity = new Map(currentReview.issues.map(issue =>
340
+ [`${issue.viewId}|${issue.objectId ?? ''}|${issue.code}`, severity[issue.severity] ?? 0]))
341
+ const worsenedIssue = after.issues.some(issue => (severity[issue.severity] ?? 0) >= 2 &&
342
+ (severity[issue.severity] ?? 0) > (priorSeverity.get(`${issue.viewId}|${issue.objectId ?? ''}|${issue.code}`) ?? 0))
343
+ const artRegression = afterArtistic && artisticRegressed(artistic, afterArtistic)
344
+ const artImproved = afterArtistic?.comparison.verdict === 'improved'
345
+ const comparisonSupported = ['improved', 'equivalent'].includes(afterArtistic?.comparison.verdict)
248
346
 
249
- if (after.score > score) {
347
+ if (after.score >= score && !worsenedIssue && !artRegression && comparisonSupported && (after.score > score || artImproved)) {
250
348
  const previousScore = score
251
349
  score = after.score
252
350
  currentRevision = summary.revision
253
351
  currentReview = after
352
+ artistic = afterArtistic
254
353
  round.outcome = 'applied'
255
- round.reason = `score ${previousScore} -> ${score}`
354
+ round.reason = previousScore === score ? `artistic improvement; technical score unchanged (${score})` : `score ${previousScore} -> ${score}`
256
355
  rounds.push(round)
257
- updateFingerprintCounters(fingerprints, after.issues)
356
+ updateFingerprintCounters(fingerprints, reviewIssues(after, artistic))
357
+ if (artImproved) for (const key of fingerprints.keys()) {
358
+ if (key.startsWith('artistic|')) fingerprints.set(key, 0)
359
+ }
258
360
  log(`round ${iteration}: applied ${operations.length} operation(s), score ${previousScore} -> ${score} on ${currentRevision}`)
259
361
  continue
260
362
  }
@@ -263,35 +365,41 @@ export async function runVisualLoop(input) {
263
365
  await input.restore({
264
366
  projectId: input.projectId,
265
367
  revision: round.revision,
266
- reason: `visual round ${iteration} measured ${after.score}, not better than ${score}`,
368
+ expectedCurrentRevision: summary.revision,
369
+ reason: `visual round ${iteration} declined: technical ${score} -> ${after.score}, artistic ${afterArtistic?.comparison.verdict ?? 'unassessable'}`,
267
370
  })
268
371
  round.outcome = 'rejected'
269
- round.reason = `score did not improve (${score} -> ${after.score})`
372
+ round.reason = artRegression ? 'ARTISTIC_REGRESSION' : worsenedIssue ? 'TECHNICAL_ISSUE_REGRESSION'
373
+ : after.score > score && !comparisonSupported ? 'ARTISTIC_COMPARISON_UNASSESSABLE' : `score did not improve (${score} -> ${after.score})`
270
374
  round.rolledBackTo = round.revision
271
375
  rounds.push(round)
272
376
  // The measured state is unchanged (the pointer went back), so the same
273
377
  // fingerprints are present again — which is exactly what makes a fix that
274
378
  // 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}`)
379
+ updateFingerprintCounters(fingerprints, reviewIssues(currentReview, artistic))
380
+ log(`round ${iteration}: rejected — ${round.reason}; pointer restored to ${currentRevision}`)
277
381
  }
278
382
 
279
383
  // A handover is something a human has to pick up, so a run that PASSED has none.
280
384
  // Reporting one would tell a reader to take over work that is finished.
281
- const handover = handedOver === null || handedOver.reason === 'PASSING_SCORE'
385
+ const handover = handedOver === null || handedOver.reason === 'PASSING_REVIEW'
282
386
  ? null
283
- : buildHandover({ handedOver, openIssues: currentReview.issues, rounds, review: currentReview })
387
+ : { ...buildHandover({ handedOver, openIssues: currentReview.issues, rounds, review: currentReview }), artistic }
284
388
 
285
389
  return {
286
390
  projectId: input.projectId,
287
391
  startRevision: input.revision,
288
392
  finalRevision: currentRevision,
393
+ subjectFixed,
394
+ fixedSubjectId: subjectFixed ? fixedSubjectId : null,
289
395
  startScore: rounds[0].score,
290
396
  finalScore: score,
291
- passed: score >= VISUAL_PASS_SCORE,
397
+ technicalPassed: !baselineSubjectError && score >= VISUAL_PASS_SCORE,
398
+ artistic,
399
+ passed: !baselineSubjectError && score >= VISUAL_PASS_SCORE && artistic.status === 'pass',
292
400
  iterations: iteration,
293
401
  maxIterations,
294
- stopReason: handedOver?.reason ?? 'PASSING_SCORE',
402
+ stopReason: handedOver?.reason ?? 'PASSING_REVIEW',
295
403
  rounds,
296
404
  openIssues: currentReview.issues,
297
405
  sheet: currentReview.sheet ?? null,
@@ -312,12 +420,21 @@ export async function runVisualLoop(input) {
312
420
  */
313
421
  function objectIdsOf(review) {
314
422
  const ids = new Set()
423
+ for (const entity of review.sceneContext?.entities ?? []) ids.add(entity.id)
424
+ for (const view of review.views ?? []) for (const object of view.objects ?? []) ids.add(object.id)
315
425
  for (const issue of review.issues ?? []) {
316
426
  if (issue.objectId) ids.add(issue.objectId)
317
427
  }
318
428
  return [...ids]
319
429
  }
320
430
 
431
+ /** Repeated unresolved art dimensions count separately from measured issues. */
432
+ function reviewIssues(review, artistic) {
433
+ return [...review.issues, ...Object.entries(artistic.dimensions)
434
+ .filter(([, entry]) => entry.status === 'needs_work')
435
+ .map(([dimension]) => ({ fingerprint: `artistic|${dimension}` }))]
436
+ }
437
+
321
438
  /**
322
439
  * The package a human needs to take over (SPEC §20 M2 "失败可人工接管").
323
440
  *
@@ -344,7 +461,7 @@ function buildHandover(input) {
344
461
  for (const viewId of investigatedViews) suggestions.push(`Re-render view "${viewId}" alone at full resolution before editing.`)
345
462
  if (attempted.length > 0) {
346
463
  suggestions.push(
347
- `The automated rounds tried ${attempted.length} change(s) and none improved the measured score; ` +
464
+ `The automated rounds tried ${attempted.length} change(s); ` +
348
465
  'the review rounds below record exactly what each one was.',
349
466
  )
350
467
  }
@@ -390,6 +507,67 @@ function normaliseOperation(proposed) {
390
507
  return [operation]
391
508
  }
392
509
 
510
+ function reviewInputsChanged(message) {
511
+ return Object.assign(new Error(message), { reviewInputsChanged: true })
512
+ }
513
+
514
+ function hasSubjectEvidence(review) {
515
+ return Object.hasOwn(review, 'subjectId') || review.subject !== undefined || review.sceneContext != null
516
+ }
517
+
518
+ function reviewedSubjectIssue(review, expectedId, required = {}) {
519
+ const issue = (reason, message) => Object.assign(new Error(message), { reviewSubjectReason: reason })
520
+ if ((required.sceneContext && review.sceneContext == null) || (required.subject && review.subject == null) ||
521
+ !Object.hasOwn(review, 'subjectId') ||
522
+ (review.subjectId !== null && (typeof review.subjectId !== 'string' || review.subjectId.length === 0)) ||
523
+ review.subjectId !== expectedId ||
524
+ (review.subject && (review.subject.id !== expectedId || typeof review.subject.available !== 'boolean'))) {
525
+ return issue('REVIEW_SUBJECT_CHANGED', 'The review did not report the fixed subject identity.')
526
+ }
527
+ if (expectedId === null || review.subject?.available === false) {
528
+ return issue('REVIEW_SUBJECT_UNAVAILABLE', review.subject?.reason ?? 'The fixed review subject is unavailable.')
529
+ }
530
+ if (review.sceneContext) {
531
+ const entity = (review.sceneContext.entities ?? []).find(entry => entry.id === expectedId)
532
+ if (!entity || entity.visible === false || entity.type === 'empty') {
533
+ return issue('REVIEW_SUBJECT_UNAVAILABLE', `The fixed review subject "${expectedId}" is missing, hidden or empty in this revision.`)
534
+ }
535
+ const authored = review.sceneContext.project?.reviewSubjectId
536
+ if (authored !== undefined && authored !== expectedId) {
537
+ return issue('REVIEW_SUBJECT_CHANGED', 'The review subject disagrees with the explicitly saved subject.')
538
+ }
539
+ }
540
+ return null
541
+ }
542
+
543
+ /** Old measurement-only ports have neither field; retain their null identity. */
544
+ function checkedReviewInputsDigest(review) {
545
+ const computed = review.sceneContext ? reviewInputsDigest(review.sceneContext) : null
546
+ const declared = review.reviewInputsDigest ?? null
547
+ if (declared !== null && (!/^[a-f0-9]{64}$/.test(declared) || (computed !== null && declared !== computed))) {
548
+ throw reviewInputsChanged('The review input digest does not match its authored scene context.')
549
+ }
550
+ if (computed === null && declared === null && (review.referenceImages ?? []).length > 0) {
551
+ throw reviewInputsChanged('A visual review with references must record its review input digest.')
552
+ }
553
+ return computed ?? declared
554
+ }
555
+
556
+ function assessArtisticReview(raw, review, minConfidence) {
557
+ const referenceImages = review.referenceImages ?? []
558
+ const authored = review.sceneContext?.project?.referenceImages ?? []
559
+ const supplied = new Map(referenceImages.map(reference => [reference.id, reference]))
560
+ const missing = authored.some(reference => {
561
+ const actual = supplied.get(reference.id)
562
+ return !actual || actual.assetId !== reference.assetId || actual.sha256 !== reference.sha256 ||
563
+ JSON.stringify([...(actual.purposes ?? [])].sort()) !== JSON.stringify([...reference.purposes].sort())
564
+ }) || (review.sceneContext && referenceImages.length !== authored.length)
565
+ const assessment = validateArtisticReview(missing ? null : raw,
566
+ new Set(review.perView.map(entry => entry.viewId)), minConfidence, { referenceImages, subject: review.subject })
567
+ if (missing) assessment.problems.push('The actual reference image inventory does not match the authored references.')
568
+ return assessment
569
+ }
570
+
393
571
  /** A positive integer option, with a default. */
394
572
  function positiveInteger(value, fallback) {
395
573
  return Number.isInteger(value) && value > 0 ? value : fallback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-contracts",
3
- "version": "0.2.20",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Studio Service Definition, protocol contracts, and stable error codes.",
6
6
  "main": "lib/index.js",
@@ -8,6 +8,13 @@
8
8
  ".": {
9
9
  "default": "./lib/index.js"
10
10
  },
11
+ "./sdk": {
12
+ "types": "./lib/sdk.d.ts",
13
+ "default": "./lib/sdk.js"
14
+ },
15
+ "./schemas/scene-spec.json": "./lib/schemas/scene-spec.schema.json",
16
+ "./schemas/scene-patch.json": "./lib/schemas/scene-patch.schema.json",
17
+ "./schemas/recipe.json": "./lib/schemas/recipe.schema.json",
11
18
  "./package.json": "./package.json"
12
19
  },
13
20
  "license": "MIT",