@nodaro/prompts 1.11.0 → 1.13.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.
Files changed (36) hide show
  1. package/dist/index.cjs +627 -177
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +726 -33
  4. package/dist/index.d.ts +726 -33
  5. package/dist/index.js +598 -179
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/__tests__/__snapshots__/entity-convergence-image.test.ts.snap +19 -0
  9. package/src/__tests__/animal-getters-parity.test.ts +82 -0
  10. package/src/__tests__/assemble-image-input-cap.test.ts +236 -0
  11. package/src/__tests__/assemble-image-input.test.ts +100 -19
  12. package/src/__tests__/assemble-video-input-cap.test.ts +442 -0
  13. package/src/__tests__/assemble-video-input.test.ts +167 -33
  14. package/src/__tests__/direction-hint-token-safety.test.ts +21 -0
  15. package/src/__tests__/entity-convergence-image.test.ts +374 -0
  16. package/src/__tests__/location-convergence-image.test.ts +29 -1
  17. package/src/__tests__/location-default-role-image.test.ts +166 -0
  18. package/src/__tests__/mention-splice-spacing.test.ts +257 -0
  19. package/src/__tests__/prompt-style-section.test.ts +345 -0
  20. package/src/__tests__/read-node-subject.test.ts +140 -0
  21. package/src/__tests__/style-section-boundary.test.ts +179 -0
  22. package/src/__tests__/subject-fold.test.ts +251 -0
  23. package/src/__tests__/subject-registry.test.ts +312 -0
  24. package/src/assemble-image-input.ts +169 -41
  25. package/src/assemble-video-input.ts +200 -25
  26. package/src/direction-registry.ts +116 -28
  27. package/src/hint-shedding.ts +87 -0
  28. package/src/index.ts +3 -0
  29. package/src/parameter-prompt-hint.ts +8 -7
  30. package/src/picker-catalogs.ts +14 -7
  31. package/src/prompt-builder.ts +628 -88
  32. package/src/prompt-hint-join.ts +9 -0
  33. package/src/prompt-style-section.ts +256 -0
  34. package/src/read-node-direction.ts +60 -1
  35. package/src/subject-registry.ts +464 -0
  36. package/src/video-reference-resolver.ts +5 -2
@@ -0,0 +1,442 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { composeVideoPromptText } from "../assemble-video-input.js"
3
+ import { resolveVideoReferenceCore } from "../video-reference-resolver.js"
4
+ import { renderDirectionHints, VIDEO_HINT_MODE_DEFAULT } from "../direction-registry.js"
5
+ import { renderSubjectHints, SUBJECT_VIDEO_HINT_MODE_DEFAULT } from "../subject-registry.js"
6
+ import { renderStructuredFields } from "../prompt-builder-structured-fields.js"
7
+ import {
8
+ composeSectionedPrompt,
9
+ partitionStyleClauses,
10
+ renderStyleSection,
11
+ } from "../prompt-style-section.js"
12
+ import { joinPromptHints } from "../prompt-hint-join.js"
13
+ import { getMaxVideoPromptChars } from "@nodaro/shared"
14
+ import type { ConnectedReference } from "@nodaro/shared"
15
+
16
+ /**
17
+ * TRUNCATION ORDERING, VIDEO HALF — `composeVideoPromptText` sheds its own hint
18
+ * clauses before the provider clamp's ORDER-BLIND tail cut can reach a
19
+ * reference binding or the user's prose. The image twin is
20
+ * `assemble-image-input-cap.test.ts`; this suite mirrors its shape.
21
+ *
22
+ * THE ORDERING PROBLEM THIS SIDE HAS AND THE IMAGE SIDE DID NOT: the fold runs
23
+ * BEFORE `resolveVideoReferenceCore`, and the resolver then ADDS binding text —
24
+ * lock lines ahead of the body, role phrases spliced in at the end of it, just
25
+ * before the `[style]` section. None of it is sheddable and all of it counts
26
+ * against the ceiling, so the shed is decided on the FRAMED length
27
+ * (`opts.frame`), not on the folded body: the resolver's additions are inside
28
+ * the budget, while the only thing the composer can drop is a clause it
29
+ * rendered itself. The frame below is the real resolver.
30
+ *
31
+ * Video caps are far tighter than the image side's (kling = 1000 vs seedream =
32
+ * 3000), so an ordinary direction overflows without any contrived prose.
33
+ */
34
+
35
+ // A broad but ordinary video direction — the kind a "set every picker" UI emits.
36
+ // Ids that don't resolve contribute nothing (registry tolerance); what matters
37
+ // is that the fold is large enough to overflow kling.
38
+ const DIRECTION = {
39
+ cameraMotion: "handheld",
40
+ shotSize: "wide-shot",
41
+ angle: "low-angle",
42
+ timeOfDay: "golden-hour",
43
+ lightingStyle: "rembrandt",
44
+ colorLook: "teal-orange",
45
+ atmosphere: ["fog"],
46
+ style: "cinematic",
47
+ mood: ["happy", "joyful"],
48
+ setting: "forest",
49
+ } as const
50
+
51
+ const VIDEO_HINTS = renderDirectionHints(DIRECTION, {
52
+ surface: "video",
53
+ mode: VIDEO_HINT_MODE_DEFAULT,
54
+ })
55
+
56
+ /** The same clauses, slotted — the shed keeps a PREFIX of exactly this list. */
57
+ const DIRECTION_CLAUSES = partitionStyleClauses(DIRECTION, {
58
+ surface: "video",
59
+ mode: VIDEO_HINT_MODE_DEFAULT,
60
+ })
61
+
62
+ /** Everything before the `[style]` section — the half the shed budget grows. */
63
+ const bodyOf = (composed: string): string => composed.split("\n\n[style]:\n")[0]!
64
+
65
+ /** The mentioned character — hybrid replaces the mention INLINE, mid-prose. */
66
+ const KIRA: ConnectedReference = {
67
+ id: "kira-id",
68
+ defaultName: "Kira",
69
+ source: "wired-character",
70
+ url: "https://r2.example/kira.png",
71
+ characterSlug: "kira",
72
+ variantSlug: undefined,
73
+ characterCanonicalDescription: "a young woman with copper hair",
74
+ variantDescription: null,
75
+ variantDisplayName: "canonical",
76
+ }
77
+
78
+ /** An UNMENTIONED wired character — hybrid renders its canonical-fallback role
79
+ * phrase at the very END, behind every folded hint. The order-blind casualty. */
80
+ const RAY: ConnectedReference = {
81
+ id: "ray-id",
82
+ defaultName: "Ray",
83
+ source: "wired-character",
84
+ url: "https://r2.example/ray.png",
85
+ characterSlug: "ray",
86
+ variantSlug: undefined,
87
+ characterCanonicalDescription: "a grizzled dockworker",
88
+ variantDescription: null,
89
+ variantDisplayName: "canonical",
90
+ }
91
+
92
+ const KLING_CAP = getMaxVideoPromptChars("kling")
93
+
94
+ /** Prose long enough that prose + bindings + the full fold clears 1000. */
95
+ const PROSE = "@kira:1 walks the seawall at dusk. " + "The waves are loud. ".repeat(14)
96
+
97
+ /**
98
+ * The FRAME under test: the production reference resolver in the hybrid format,
99
+ * exactly the shape the routes' `assembleVideoConnectedReferences` drives it in.
100
+ * Pure, so the composer may call it once per shed iteration.
101
+ */
102
+ const frame = (body: string | undefined): string | undefined =>
103
+ resolveVideoReferenceCore({
104
+ prompt: body,
105
+ wiredCharRefs: [KIRA, RAY],
106
+ hybridRoles: true,
107
+ }).prompt
108
+
109
+ /** The binding that lands inline, inside the prose. */
110
+ const MENTION_BINDING = "@image_1"
111
+ /** Ray is unmentioned → his canonical-fallback phrase ends the BODY, spliced in
112
+ * ahead of the `[style]` section. */
113
+ const TRAILING_BINDING = "@image_2"
114
+
115
+ /** The whole look section, unshed — what an order-blind cut severs first. */
116
+ const FULL_SECTION = renderStyleSection(DIRECTION, {
117
+ surface: "video",
118
+ mode: VIDEO_HINT_MODE_DEFAULT,
119
+ })
120
+
121
+ describe("composeVideoPromptText — cap-aware hint shedding", () => {
122
+ it("the unshed fold really does overflow kling through the frame (non-vacuity guard)", () => {
123
+ // The oracle for "what the composer produced before": fold every hint, hand
124
+ // the body to the resolver, let the provider clamp decide. If catalog
125
+ // wording ever shrinks enough that this stops overflowing, every scenario
126
+ // below is vacuous and this assertion says so loudly.
127
+ const naive = frame(composeVideoPromptText(PROSE, DIRECTION))!
128
+ expect(naive.length).toBeGreaterThan(KLING_CAP)
129
+ // …and what a tail cut at the cap would destroy is the look section,
130
+ // mid-clause: the role phrase splices into the body ahead of it and clears
131
+ // the cut, so the shed's job is to drop whole clauses instead.
132
+ expect(naive.slice(0, KLING_CAP)).toContain(TRAILING_BINDING)
133
+ expect(naive.slice(0, KLING_CAP)).not.toContain(FULL_SECTION)
134
+ })
135
+
136
+ it("keeps every binding and the full prose, dropping trailing hints", () => {
137
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
138
+ cap: KLING_CAP,
139
+ frame,
140
+ })!
141
+ const framed = frame(body)!
142
+
143
+ // Fits — the shed resolved the whole overflow, so the provider clamp is
144
+ // never reached and nothing is cut mid-word.
145
+ expect(framed.length).toBeLessThanOrEqual(KLING_CAP)
146
+
147
+ // Both bindings survive: the mention resolved inline AND the trailing
148
+ // canonical-fallback role phrase the resolver appends last.
149
+ expect(framed).toContain(MENTION_BINDING)
150
+ expect(framed).toContain(TRAILING_BINDING)
151
+
152
+ // The user's prose survives IN FULL and still leads the body (the hint join
153
+ // trims its trailing space), and it survives the framing too — the mention
154
+ // resolving inline to its role phrase is the only edit it takes.
155
+ expect(body.startsWith(PROSE.trim())).toBe(true)
156
+ expect(framed).toContain(PROSE.replace("@kira:1", `the person from ${MENTION_BINDING}`).trim())
157
+
158
+ // The LAST-folded hint clause is gone; the FIRST-folded one stayed. Shedding
159
+ // walks the fold order from the tail, so the dimensions the registry folds
160
+ // first outlive the ones it folds last.
161
+ expect(body).not.toContain(VIDEO_HINTS[VIDEO_HINTS.length - 1])
162
+ expect(body).toContain(VIDEO_HINTS[0])
163
+ })
164
+
165
+ it("sheds more as the cap tightens, and everything at a cap prose alone can't meet", () => {
166
+ const roomy = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: KLING_CAP, frame })!
167
+ const tight = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 700, frame })!
168
+ expect(tight.length).toBeLessThan(roomy.length)
169
+
170
+ // A cap the framed prose alone cannot meet sheds every hint and then stops —
171
+ // prose is never touched, and the provider clamp stays the last resort.
172
+ const starved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 10, frame })
173
+ expect(starved).toBe(PROSE)
174
+ for (const hint of VIDEO_HINTS) expect(starved).not.toContain(hint)
175
+ // A FULL shed takes the header with it — byte-identical to the prompt, not
176
+ // an empty section hanging off it. This is what keeps the routes'
177
+ // `composed !== prompt` guard reading false when nothing survived.
178
+ expect(starved).not.toContain("[style]")
179
+ })
180
+
181
+ it("survives the reference resolver byte-intact", () => {
182
+ // The resolver is why the section is written flush-left: it collapses 2+
183
+ // HORIZONTAL spaces unanchored, and it rewrites mentions and appends role
184
+ // phrases around the body. None of that may touch the section's bytes.
185
+ const body = composeVideoPromptText(PROSE, DIRECTION)!
186
+ const section = body.slice(body.indexOf("\n\n[style]:\n"))
187
+ expect(section).toContain("[style]:\n")
188
+ // ENDS with it, not merely contains it: the resolver's role phrases splice
189
+ // into the body ahead of the section, so nothing of the resolver's may
190
+ // extend the clause block the header opens.
191
+ expect(frame(body)!.endsWith(section)).toBe(true)
192
+ })
193
+
194
+ it("reclaims the header only when the LAST look clause sheds", () => {
195
+ // Budgets derived from what the composer actually builds, so they track
196
+ // catalog wording instead of pinning it.
197
+ const capForKept = (n: number): number =>
198
+ frame(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, n), ""))!.length
199
+ // The first clause is `cameraMotion` (motion → body), the second the first
200
+ // LOOK clause — so `kept = 2` is "body plus exactly one section clause".
201
+ expect(DIRECTION_CLAUSES[0]!.slot).toBe("body")
202
+ expect(DIRECTION_CLAUSES[1]!.slot).not.toBe("body")
203
+
204
+ const atTwo = composeVideoPromptText(PROSE, DIRECTION, undefined, {
205
+ cap: capForKept(2),
206
+ frame,
207
+ })!
208
+ expect(atTwo).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 2), ""))
209
+ expect(atTwo).toContain("[style]:")
210
+
211
+ // ONE byte tighter, and the section's last clause goes — taking the whole
212
+ // 11-byte `"\n\n[style]:\n"` with it, so the body drops all the way back to
213
+ // the prose. (The cost of under-pricing that header instead shows up as an
214
+ // over-shed in "sheds the whole direction fold before a single subject
215
+ // clause" below, which is where a flat per-clause charge fails.)
216
+ const justUnder = composeVideoPromptText(PROSE, DIRECTION, undefined, {
217
+ cap: capForKept(2) - 1,
218
+ frame,
219
+ })!
220
+ expect(justUnder).toBe(composeSectionedPrompt(PROSE, DIRECTION_CLAUSES.slice(0, 1), ""))
221
+ expect(justUnder).not.toContain("[style]")
222
+ })
223
+
224
+ it("reserves the caller's budget rather than re-deriving a provider cap", () => {
225
+ // The routes pass `effectiveVideoPromptCeiling`, which for a NON-native
226
+ // negative provider is `cap - "\nAvoid: …".length`. The composer must honor
227
+ // that reduced number verbatim — a composer that read `getMaxVideoPromptChars`
228
+ // itself would shed too little and let the clamp sever the Avoid suffix's
229
+ // room. `minimax` is outside NATIVE_NEGATIVE_VIDEO_PROVIDERS, so its ceiling
230
+ // really does shrink.
231
+ const rawCap = getMaxVideoPromptChars("minimax")
232
+ const reserved = rawCap - "\nAvoid: blurry, low quality".length
233
+ const atRaw = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: rawCap, frame })!
234
+ const atReserved = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: reserved, frame })!
235
+ expect(frame(atReserved)!.length).toBeLessThanOrEqual(reserved)
236
+ expect(atReserved.length).toBeLessThanOrEqual(atRaw.length)
237
+ })
238
+
239
+ it("never sheds the structured fragment — it is user content, not a garnish", () => {
240
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
241
+ const fragment = renderStructuredFields(structured)
242
+ expect(fragment.length, "an empty fragment makes every claim below vacuous").toBeGreaterThan(0)
243
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured, { cap: 700, frame })!
244
+ expect(body).toContain(fragment)
245
+ // …and it still ends the BODY, behind every surviving body hint.
246
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
247
+ })
248
+
249
+ it("ends the BODY with the fragment even when the section survives above it", () => {
250
+ // The capless fold, where every clause lives: the fragment is the last
251
+ // thing in the body and the section reads after it, so the composed prompt
252
+ // does NOT end with the fragment any more.
253
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
254
+ const fragment = renderStructuredFields(structured)
255
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured)!
256
+ expect(body).toContain("\n\n[style]:\n")
257
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
258
+ expect(body.endsWith(fragment)).toBe(false)
259
+ })
260
+ })
261
+
262
+ describe("composeVideoPromptText — under-cap byte parity", () => {
263
+ // The oracle is literally the capless call. A framed prompt that FITS must be
264
+ // byte-identical to it, so the whole leg lands dark for every under-cap run.
265
+ const parityCases: ReadonlyArray<{ name: string; provider: string; prompt: string }> = [
266
+ { name: "high-cap provider with the same fold", provider: "seedance-2", prompt: PROSE },
267
+ { name: "low-cap provider, short prose", provider: "kling", prompt: "a knight on a hill" },
268
+ ]
269
+
270
+ for (const { name, provider, prompt } of parityCases) {
271
+ it(`is byte-identical to the capless fold — ${name}`, () => {
272
+ const cap = getMaxVideoPromptChars(provider)
273
+ const expected = composeVideoPromptText(prompt, DIRECTION)
274
+ const actual = composeVideoPromptText(prompt, DIRECTION, undefined, { cap, frame })
275
+ expect(actual).toBe(expected)
276
+ // Guard the guard: a case that overflowed would prove nothing.
277
+ expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
278
+ })
279
+ }
280
+
281
+ it("leaves the no-direction platform-caller path an exact no-op under a cap", () => {
282
+ // No hints → nothing droppable → the prompt comes back verbatim and
283
+ // UNTRIMMED, `undefined` included, exactly as before, even on a tiny cap.
284
+ expect(composeVideoPromptText(" \n", undefined, undefined, { cap: 1, frame })).toBe(" \n")
285
+ expect(composeVideoPromptText(undefined, undefined, undefined, { cap: 1, frame })).toBeUndefined()
286
+ expect(composeVideoPromptText("a knight", {}, undefined, { cap: 1, frame })).toBe("a knight")
287
+ })
288
+
289
+ it("treats an absent frame as identity, and an absent cap as no shedding at all", () => {
290
+ // A caller with a cap but no references measures the body itself…
291
+ const framedless = composeVideoPromptText(PROSE, DIRECTION, undefined, { cap: 400 })!
292
+ expect(framedless.length).toBeLessThanOrEqual(400)
293
+ // …and a caller with no cap gets the full fold no matter how long it is.
294
+ expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { frame })).toBe(
295
+ composeVideoPromptText(PROSE, DIRECTION),
296
+ )
297
+ })
298
+ })
299
+
300
+ /**
301
+ * THE SUBJECT CHANNEL UNDER THE SAME CAP. The decision this pins: subject
302
+ * clauses ARE shed candidates, exactly like direction clauses — both are
303
+ * catalog decoration the platform rendered from ids — and they shed AFTER the
304
+ * direction fold, because both channels ride ONE list (`[...subject,
305
+ * ...direction]`) that the shared `hint-shedding.ts` arithmetic walks TAIL
306
+ * FIRST. Exempting the subject fold would not save it: a fully specified person
307
+ * is the largest single fold on the surface, so the overflow would simply land
308
+ * in the provider's order-blind clamp and sever a binding or the prose instead.
309
+ *
310
+ * The image twin of this ordering is pinned in `subject-fold.test.ts`; what is
311
+ * new HERE is the composition the video surface only gained once the cap and
312
+ * the subject channel met — including the SUBJECT-ONLY fold, which neither
313
+ * channel's own suite exercises under a cap.
314
+ *
315
+ * Caps are derived from the framed body rather than hardcoded, so the cases
316
+ * keep testing the claim when catalog wording changes.
317
+ */
318
+ describe("composeVideoPromptText — the subject fold under the cap", () => {
319
+ const SUBJECT = {
320
+ type: "woman",
321
+ ethnicity: "east-asian",
322
+ hairBase: "base-short-straight",
323
+ makeup: "makeup-smoky",
324
+ animal: "dog-corgi",
325
+ } as const
326
+
327
+ const SUBJECT_HINTS = renderSubjectHints(SUBJECT, {
328
+ surface: "video",
329
+ mode: SUBJECT_VIDEO_HINT_MODE_DEFAULT,
330
+ })
331
+
332
+ /**
333
+ * Framed length of the body that folds exactly the first `n` subject clauses
334
+ * and no direction at all — i.e. the tightest budget under which the shed
335
+ * should settle on `n` kept clauses. Built the same way the composer builds
336
+ * it, so the derived caps track catalog wording instead of pinning it.
337
+ */
338
+ const framedWithSubjectClauses = (n: number): number =>
339
+ frame(n === 0 ? PROSE : joinPromptHints(PROSE, SUBJECT_HINTS.slice(0, n)))!.length
340
+
341
+ it("sheds the whole direction fold before a single subject clause", () => {
342
+ // Non-vacuity: subject + direction together really do overflow the frame.
343
+ const unshed = frame(
344
+ composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: SUBJECT }),
345
+ )!
346
+ expect(unshed.length).toBeGreaterThan(KLING_CAP)
347
+ expect(SUBJECT_HINTS.length).toBeGreaterThan(1)
348
+
349
+ // A budget that fits the prose plus the FULL subject fold and nothing else.
350
+ const cap = framedWithSubjectClauses(SUBJECT_HINTS.length)
351
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
352
+ subject: SUBJECT,
353
+ cap,
354
+ frame,
355
+ })!
356
+ expect(frame(body)!.length).toBeLessThanOrEqual(cap)
357
+ // Every subject clause survived; the direction fold paid the whole bill.
358
+ for (const hint of SUBJECT_HINTS) expect(body).toContain(hint)
359
+ for (const hint of VIDEO_HINTS) expect(body).not.toContain(hint)
360
+ // …and the prose and both bindings are untouched, as always.
361
+ expect(body.startsWith(PROSE.trim())).toBe(true)
362
+ expect(frame(body)!).toContain(MENTION_BINDING)
363
+ expect(frame(body)!).toContain(TRAILING_BINDING)
364
+ })
365
+
366
+ it("then sheds subject clauses too, last-folded first, once direction is gone", () => {
367
+ // Tighter than the prose plus the full subject fold → the tail of the
368
+ // SUBJECT list has to go as well. This is the decision: subject clauses are
369
+ // shed candidates, not a protected channel.
370
+ const cap = framedWithSubjectClauses(SUBJECT_HINTS.length - 1)
371
+ const body = composeVideoPromptText(PROSE, DIRECTION, undefined, {
372
+ subject: SUBJECT,
373
+ cap,
374
+ frame,
375
+ })!
376
+ expect(frame(body)!.length).toBeLessThanOrEqual(cap)
377
+ expect(body).not.toContain(SUBJECT_HINTS[SUBJECT_HINTS.length - 1])
378
+ expect(body).toContain(SUBJECT_HINTS[0])
379
+ })
380
+
381
+ it("sheds a SUBJECT-ONLY fold, and still never the prose or the bindings", () => {
382
+ // The composition neither parent shipped: `subject` with a cap and no
383
+ // `direction` at all — the shape a subject-only route request takes.
384
+ const cap = framedWithSubjectClauses(0)
385
+ const body = composeVideoPromptText(PROSE, undefined, undefined, {
386
+ subject: SUBJECT,
387
+ cap,
388
+ frame,
389
+ })!
390
+ // Everything droppable is gone, so the body is the prose VERBATIM (the
391
+ // `kept === 0` no-op branch), which is what leaves the route's
392
+ // `composed !== prompt` guard correctly unpinned.
393
+ expect(body).toBe(PROSE)
394
+ for (const hint of SUBJECT_HINTS) expect(body).not.toContain(hint)
395
+ const framed = frame(body)!
396
+ expect(framed).toContain(MENTION_BINDING)
397
+ expect(framed).toContain(TRAILING_BINDING)
398
+ })
399
+
400
+ it("never sheds the structured fragment to save a subject clause", () => {
401
+ // Ordering across ALL THREE pieces at once: user content outranks both
402
+ // catalog channels and still lands last.
403
+ const structured = { person: { profession: "a lighthouse keeper", expression: "focused" } }
404
+ const fragment = renderStructuredFields(structured)
405
+ const body = composeVideoPromptText(PROSE, DIRECTION, structured, {
406
+ subject: SUBJECT,
407
+ cap: framedWithSubjectClauses(0),
408
+ frame,
409
+ })!
410
+ expect(bodyOf(body).endsWith(fragment)).toBe(true)
411
+ for (const hint of [...SUBJECT_HINTS, ...VIDEO_HINTS]) {
412
+ expect(body).not.toContain(hint)
413
+ }
414
+ // Everything droppable went, so there is no section left to end with.
415
+ expect(body).not.toContain("[style]")
416
+ })
417
+
418
+ it("is byte-identical to the capless subject fold when it fits", () => {
419
+ // The under-cap parity oracle, extended to the subject channel: a fold that
420
+ // fits must not be able to tell it was budgeted.
421
+ const short = "a knight on a hill"
422
+ const cap = getMaxVideoPromptChars("seedance-2")
423
+ const expected = composeVideoPromptText(short, DIRECTION, undefined, { subject: SUBJECT })
424
+ const actual = composeVideoPromptText(short, DIRECTION, undefined, {
425
+ subject: SUBJECT,
426
+ cap,
427
+ frame,
428
+ })
429
+ expect(actual).toBe(expected)
430
+ expect(frame(expected)!.length).toBeLessThanOrEqual(cap)
431
+ })
432
+
433
+ it("leaves a subject-less request byte-identical under a cap (the parity oracle)", () => {
434
+ // The channel must stay dark: no `subject` → exactly what the direction-only
435
+ // cap path produced before the two met.
436
+ for (const cap of [KLING_CAP, 700, 10]) {
437
+ expect(composeVideoPromptText(PROSE, DIRECTION, undefined, { cap, frame })).toBe(
438
+ composeVideoPromptText(PROSE, DIRECTION, undefined, { subject: {}, cap, frame }),
439
+ )
440
+ }
441
+ })
442
+ })