writer: the rail owns mp, and the schema requires both documents
Browse filesTwo faults, both surfaced by swapping the writer model.
`mp` never survived a Write plan. `railRefs()` and `_pinned_refs()` dropped
the field, the model cannot author it (it is not in the prompt's valid-field
list), and Accept overwrites the register wholesale -- so every write
silently reset every cap to "full". chain_00047 ran three plates at 0.54 MP;
the next write put the same three back at native size, 3.23 MP of stills
against a 0.72 MP canvas, and the render came back as the reference
photograph instead of the beat. The rail is authoritative now: its value is
restored, and a value it does not have is REMOVED rather than left as
written. gemma4-26b read the units as pixels and returned 1000000000, which
cleared the 0.3 floor because there was no ceiling; refs.py has one, and the
rejection names the units.
SCHEMA.json had no top-level `required`, so a reply of {"shot_plan": ...}
alone was schema-valid. gemma4-26b emitted exactly that on all three
attempts: the register stayed empty, `_remap_pinned_tags` bailed on the
falsy ref_text before it could restore the rail's names, and every repair
turn was told its beats cited undeclared tags -- so it rewrote the beats it
had already got right and never emitted the document that was missing. Three
attempts and no convergence before; two attempts and ok after. Qwen3
volunteered both documents and hid this. `validate` also names the omission
directly, for use_schema=False and for servers that ignore the grammar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VB4Dw6F79643vySkf19KV
- js/editor/ref_rail.js +12 -2
- js/editor/writer_bar.js +5 -0
- planner.py +60 -0
- prompt_pack/SCHEMA.json +6 -1
- refs.py +14 -4
- routes.py +9 -0
- tools/check_planner.py +43 -0
- tools/gen_schema.py +17 -6
|
@@ -387,12 +387,22 @@ export function createRefRail(node, { getPlan, setPlan, onChange, hopCount,
|
|
| 387 |
* scales a reference DOWN -- a cap above the file's own size is a
|
| 388 |
* dial wired to nothing, and a number there would imply upscaling
|
| 389 |
* that never happens. */
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
r.mp = v ? Number(v) : 0;
|
| 392 |
commit();
|
| 393 |
}, {
|
| 394 |
blankLabel: "full",
|
| 395 |
-
titles: Object.fromEntries(
|
| 396 |
[String(v), `Cap this picture at ${v} MP before the encoder sees it.`])),
|
| 397 |
});
|
| 398 |
mp.classList.add("h3e-mp");
|
|
|
|
| 387 |
* scales a reference DOWN -- a cap above the file's own size is a
|
| 388 |
* dial wired to nothing, and a number there would imply upscaling
|
| 389 |
* that never happens. */
|
| 390 |
+
/* A cap that is not one of the offered values still has to SHOW.
|
| 391 |
+
* `select` assigns a value matching no option, which renders the
|
| 392 |
+
* control blank -- and blank here reads as "full", so a row capped
|
| 393 |
+
* at 0.54 looked uncapped while it was working. Offer the row's
|
| 394 |
+
* own value alongside the standard ones. */
|
| 395 |
+
const mpOpts = REF_MP.map(String);
|
| 396 |
+
if (r.mp && !mpOpts.includes(String(r.mp))) {
|
| 397 |
+
mpOpts.push(String(r.mp));
|
| 398 |
+
mpOpts.sort((a, b) => Number(a) - Number(b));
|
| 399 |
+
}
|
| 400 |
+
const mp = select(mpOpts, r.mp ? String(r.mp) : "", (v) => {
|
| 401 |
r.mp = v ? Number(v) : 0;
|
| 402 |
commit();
|
| 403 |
}, {
|
| 404 |
blankLabel: "full",
|
| 405 |
+
titles: Object.fromEntries(mpOpts.map((v) =>
|
| 406 |
[String(v), `Cap this picture at ${v} MP before the encoder sees it.`])),
|
| 407 |
});
|
| 408 |
mp.classList.add("h3e-mp");
|
|
@@ -333,6 +333,11 @@ export function createWriterBar(node, { onWritten, hopCount } = {}) {
|
|
| 333 |
subject: r.subject == null ? null : Number(r.subject),
|
| 334 |
retention: String(r.retention || ""),
|
| 335 |
desc: String(r.desc || ""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
}));
|
| 337 |
}
|
| 338 |
|
|
|
|
| 333 |
subject: r.subject == null ? null : Number(r.subject),
|
| 334 |
retention: String(r.retention || ""),
|
| 335 |
desc: String(r.desc || ""),
|
| 336 |
+
// The writer never sees `mp` and cannot author one, but Accept
|
| 337 |
+
// replaces the whole register with what comes back. Send it up
|
| 338 |
+
// so the server can put it back, or every write silently
|
| 339 |
+
// resets every row to "full".
|
| 340 |
+
mp: r.mp ? Number(r.mp) : null,
|
| 341 |
}));
|
| 342 |
}
|
| 343 |
|
|
@@ -157,6 +157,19 @@ def validate(shot_text, ref_text, *, hops=None, known_files=None, pinned=None):
|
|
| 157 |
errors.append(f"the plan has {len(shots)} shot(s) but {hops} hop(s) "
|
| 158 |
f"were asked for. Write exactly {hops}.")
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
try:
|
| 161 |
ref_plan = _refs.parse_ref_plan(ref_text)
|
| 162 |
except Exception as exc:
|
|
@@ -529,6 +542,50 @@ def _merge_register(base_text, patch_text, keep=None):
|
|
| 529 |
return json.dumps({"refs": refs, "subjects": subs}, indent=2)
|
| 530 |
|
| 531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
def _remap_pinned_tags(shot_text, ref_text, pinned):
|
| 533 |
"""Rename invented tags back to the rail's, matching on `file`.
|
| 534 |
|
|
@@ -789,6 +846,9 @@ async def write_plan(brief, hops, *, complete_fn, files=None,
|
|
| 789 |
ref_text = _merge_register(ref_text, new_refs, keep=rail_tags)
|
| 790 |
else:
|
| 791 |
ref_text = new_refs
|
|
|
|
|
|
|
|
|
|
| 792 |
if not shot_text:
|
| 793 |
last_errors = ["the reply contained no JSON. Answer with the two "
|
| 794 |
"JSON blocks and nothing else."]
|
|
|
|
| 157 |
errors.append(f"the plan has {len(shots)} shot(s) but {hops} hop(s) "
|
| 158 |
f"were asked for. Write exactly {hops}.")
|
| 159 |
|
| 160 |
+
# An absent register is its own fault and has to say so. Reported only as
|
| 161 |
+
# undeclared beat tags it reads as a spelling problem, and a repair turn
|
| 162 |
+
# spends itself rewriting beats that were already right: gemma4-26b went
|
| 163 |
+
# @woman_face -> @ref_1 on attempt 2 and still shipped no `ref_plan`, three
|
| 164 |
+
# attempts, no convergence. The schema now requires both documents; this is
|
| 165 |
+
# the same guard for `use_schema=False` and for servers that ignore it.
|
| 166 |
+
if not str(ref_text or "").strip():
|
| 167 |
+
errors.append(
|
| 168 |
+
"the reply had no `ref_plan` document. Answer with BOTH JSON "
|
| 169 |
+
"documents: `shot_plan` and `ref_plan`. Every @tag a beat uses "
|
| 170 |
+
"must be declared as a `tag` in ref_plan.refs.")
|
| 171 |
+
return errors, warnings
|
| 172 |
+
|
| 173 |
try:
|
| 174 |
ref_plan = _refs.parse_ref_plan(ref_text)
|
| 175 |
except Exception as exc:
|
|
|
|
| 542 |
return json.dumps({"refs": refs, "subjects": subs}, indent=2)
|
| 543 |
|
| 544 |
|
| 545 |
+
# Fields the RAIL owns and the model never authors. `mp` is a pixel budget the
|
| 546 |
+
# person sets per row; it is absent from `schema()` and from the valid-fields
|
| 547 |
+
# list in SYSTEM_PROMPT.md, so a written register cannot carry one. Accept
|
| 548 |
+
# writes that register straight onto the rail, so without this a Write plan
|
| 549 |
+
# silently reset every cap to "full": chain_00047 ran three plates at 0.54 MP
|
| 550 |
+
# (1.58 MP total against a 0.72 MP canvas) and the next write put the same
|
| 551 |
+
# three back at native size, 3.23 MP, which is the run that came back with the
|
| 552 |
+
# reference photograph rendered instead of the beat.
|
| 553 |
+
RAIL_ONLY_FIELDS = ("mp",)
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _restore_rail_only(ref_text, pinned):
|
| 557 |
+
"""Make the rail authoritative for its own per-row fields.
|
| 558 |
+
|
| 559 |
+
Authoritative, not merely restorative: a value the rail does not have is
|
| 560 |
+
REMOVED, it is not left as the model wrote it. `mp` is exposed in the
|
| 561 |
+
schema so a hand-authored plan can set one, which means a model can put a
|
| 562 |
+
number there too -- gemma4-26b read the units as pixels and returned
|
| 563 |
+
1000000000. On the pinned path the person owns that dial, so whatever came
|
| 564 |
+
back is discarded either way.
|
| 565 |
+
"""
|
| 566 |
+
obj = _parse_obj(ref_text)
|
| 567 |
+
if not isinstance(obj, dict) or not pinned:
|
| 568 |
+
return ref_text
|
| 569 |
+
by_tag = {str(p.get("tag") or "").lstrip("@").strip(): p
|
| 570 |
+
for p in pinned if isinstance(p, dict)}
|
| 571 |
+
touched = False
|
| 572 |
+
for r in (obj.get("refs") or []):
|
| 573 |
+
if not isinstance(r, dict):
|
| 574 |
+
continue
|
| 575 |
+
row = by_tag.get(str(r.get("tag") or "").lstrip("@").strip())
|
| 576 |
+
if not row:
|
| 577 |
+
continue
|
| 578 |
+
for field in RAIL_ONLY_FIELDS:
|
| 579 |
+
value = row.get(field)
|
| 580 |
+
if value in (None, "", 0):
|
| 581 |
+
if r.pop(field, None) is not None:
|
| 582 |
+
touched = True
|
| 583 |
+
elif r.get(field) != value:
|
| 584 |
+
r[field] = value
|
| 585 |
+
touched = True
|
| 586 |
+
return json.dumps(obj, indent=2) if touched else ref_text
|
| 587 |
+
|
| 588 |
+
|
| 589 |
def _remap_pinned_tags(shot_text, ref_text, pinned):
|
| 590 |
"""Rename invented tags back to the rail's, matching on `file`.
|
| 591 |
|
|
|
|
| 846 |
ref_text = _merge_register(ref_text, new_refs, keep=rail_tags)
|
| 847 |
else:
|
| 848 |
ref_text = new_refs
|
| 849 |
+
# Before validate, so a restored `mp` is what the lints and the
|
| 850 |
+
# returned plan both see, on the failure path as well as the ok one.
|
| 851 |
+
ref_text = _restore_rail_only(ref_text, pinned)
|
| 852 |
if not shot_text:
|
| 853 |
last_errors = ["the reply contained no JSON. Answer with the two "
|
| 854 |
"JSON blocks and nothing else."]
|
|
@@ -3,6 +3,10 @@
|
|
| 3 |
"title": "H3 Ref Chain plans",
|
| 4 |
"description": "Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` in the ref_plan widget. Generated by tools/gen_schema.py from the installed node -- do not edit by hand.",
|
| 5 |
"type": "object",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"properties": {
|
| 7 |
"shot_plan": {
|
| 8 |
"type": "object",
|
|
@@ -182,7 +186,8 @@
|
|
| 182 |
"mp": {
|
| 183 |
"type": "number",
|
| 184 |
"minimum": 0,
|
| 185 |
-
"
|
|
|
|
| 186 |
}
|
| 187 |
}
|
| 188 |
},
|
|
|
|
| 3 |
"title": "H3 Ref Chain plans",
|
| 4 |
"description": "Two documents. `shot_plan` goes in the shot_plan widget, `ref_plan` in the ref_plan widget. Generated by tools/gen_schema.py from the installed node -- do not edit by hand.",
|
| 5 |
"type": "object",
|
| 6 |
+
"required": [
|
| 7 |
+
"shot_plan",
|
| 8 |
+
"ref_plan"
|
| 9 |
+
],
|
| 10 |
"properties": {
|
| 11 |
"shot_plan": {
|
| 12 |
"type": "object",
|
|
|
|
| 186 |
"mp": {
|
| 187 |
"type": "number",
|
| 188 |
"minimum": 0,
|
| 189 |
+
"maximum": 16.0,
|
| 190 |
+
"description": "Per-reference pixel budget in MEGApixels, set by the person on the rail. Do not author it: omit the field. It is here so a hand-written plan can carry one, and a written value is discarded on the pinned path. A set value has a 0.3 MP floor and a 16 MP ceiling; 0 means no cap."
|
| 191 |
}
|
| 192 |
}
|
| 193 |
},
|
|
@@ -48,11 +48,17 @@ REF_FIELDS = ("tag", "file", "slot", "subject", "retention", "desc", "shots",
|
|
| 48 |
# This is a TOKEN dial, not a quality one. H3 turns each reference into
|
| 49 |
# `latent_h * latent_w` entries in the DiT payload and attends over all of them
|
| 50 |
# on every step of every hop, so a location plate costing what a face costs is
|
| 51 |
-
# waste. The floor is a picture you can still recognise a room in
|
| 52 |
-
#
|
| 53 |
-
#
|
| 54 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
REF_MP_MIN = 0.3
|
|
|
|
| 56 |
|
| 57 |
# Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
|
| 58 |
# so it is keyed by the same subject number that owns the picture ordinals.
|
|
@@ -172,6 +178,10 @@ def _norm_ref(raw, i):
|
|
| 172 |
if mp < REF_MP_MIN:
|
| 173 |
_fail(f"{where} (@{tag}): mp {mp:g} is below the {REF_MP_MIN:g} MP "
|
| 174 |
f"floor. Use 0 for no cap.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
shots = raw.get("shots")
|
| 177 |
if shots is not None:
|
|
|
|
| 48 |
# This is a TOKEN dial, not a quality one. H3 turns each reference into
|
| 49 |
# `latent_h * latent_w` entries in the DiT payload and attends over all of them
|
| 50 |
# on every step of every hop, so a location plate costing what a face costs is
|
| 51 |
+
# waste. The floor is a picture you can still recognise a room in.
|
| 52 |
+
#
|
| 53 |
+
# The ceiling is a sanity rail, not a capability limit: H3 only ever scales a
|
| 54 |
+
# reference DOWN (`min(1.0, ...)` in nodes_minimax_h3.py), so any cap above the
|
| 55 |
+
# file's own size is already a dial wired to nothing and does no harm at render
|
| 56 |
+
# time. It exists because a *model* can author this field, and one read the
|
| 57 |
+
# units as pixels: gemma4-26b returned mp 1000000000, which passed the floor,
|
| 58 |
+
# survived into the register and rendered in the rail as a dropdown full of
|
| 59 |
+
# zeroes. Anything past this is a units mistake, not an intention.
|
| 60 |
REF_MP_MIN = 0.3
|
| 61 |
+
REF_MP_MAX = 16.0
|
| 62 |
|
| 63 |
# Per-subject continuity text -- the half `HTCContinuityState` owned, moved here
|
| 64 |
# so it is keyed by the same subject number that owns the picture ordinals.
|
|
|
|
| 178 |
if mp < REF_MP_MIN:
|
| 179 |
_fail(f"{where} (@{tag}): mp {mp:g} is below the {REF_MP_MIN:g} MP "
|
| 180 |
f"floor. Use 0 for no cap.")
|
| 181 |
+
if mp > REF_MP_MAX:
|
| 182 |
+
_fail(f"{where} (@{tag}): mp {mp:g} is above the {REF_MP_MAX:g} MP "
|
| 183 |
+
f"ceiling -- this field is MEGApixels, not pixels. Use 0 for "
|
| 184 |
+
f"no cap.")
|
| 185 |
|
| 186 |
shots = raw.get("shots")
|
| 187 |
if shots is not None:
|
|
@@ -79,12 +79,21 @@ def _pinned_refs(raw, limit):
|
|
| 79 |
subj = int(subj) if subj not in (None, "", False) else None
|
| 80 |
except (TypeError, ValueError):
|
| 81 |
subj = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
out.append({
|
| 83 |
"tag": tag,
|
| 84 |
"file": fname,
|
| 85 |
"subject": subj,
|
| 86 |
"retention": str(r.get("retention") or ""),
|
| 87 |
"desc": str(r.get("desc") or ""),
|
|
|
|
| 88 |
})
|
| 89 |
if len(out) >= int(limit):
|
| 90 |
break
|
|
|
|
| 79 |
subj = int(subj) if subj not in (None, "", False) else None
|
| 80 |
except (TypeError, ValueError):
|
| 81 |
subj = None
|
| 82 |
+
# `mp` rides along so planner._restore_rail_only can put it back on the
|
| 83 |
+
# written register. The model is never shown it and cannot author it,
|
| 84 |
+
# but Accept overwrites the rail with what comes back, so a cap that
|
| 85 |
+
# does not survive this round trip is a cap the next write deletes.
|
| 86 |
+
try:
|
| 87 |
+
mp = float(r.get("mp") or 0) or None
|
| 88 |
+
except (TypeError, ValueError):
|
| 89 |
+
mp = None
|
| 90 |
out.append({
|
| 91 |
"tag": tag,
|
| 92 |
"file": fname,
|
| 93 |
"subject": subj,
|
| 94 |
"retention": str(r.get("retention") or ""),
|
| 95 |
"desc": str(r.get("desc") or ""),
|
| 96 |
+
"mp": mp,
|
| 97 |
})
|
| 98 |
if len(out) >= int(limit):
|
| 99 |
break
|
|
@@ -360,6 +360,49 @@ def main():
|
|
| 360 |
hops=2, known_files=kf)
|
| 361 |
ck("the live plan is still accepted", not live)
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
kept = PL._merge_register(
|
| 364 |
json.dumps({"refs": [{"tag": "ref_1", "file": "a.jpg",
|
| 365 |
"desc": "a woman in green"}],
|
|
|
|
| 360 |
hops=2, known_files=kf)
|
| 361 |
ck("the live plan is still accepted", not live)
|
| 362 |
|
| 363 |
+
# `mp` is the rail's, not the model's. It is absent from the schema, so
|
| 364 |
+
# every written register comes back without one -- and Accept overwrites
|
| 365 |
+
# the rail with that. chain_00047 ran three plates at 0.54 MP; the next
|
| 366 |
+
# Write plan put them back at native size (1.58 MP -> 3.23 MP against a
|
| 367 |
+
# 0.72 MP canvas) and the render came back as the reference photograph.
|
| 368 |
+
rail_mp = [{"tag": "ref_1", "file": "cook_face.png", "mp": 0.54},
|
| 369 |
+
{"tag": "ref_2", "file": "kitchen.png", "mp": 0.3}]
|
| 370 |
+
written = json.dumps({"refs": [{"tag": "ref_1", "file": "cook_face.png",
|
| 371 |
+
"subject": 1, "desc": "a face"},
|
| 372 |
+
{"tag": "ref_2", "file": "kitchen.png",
|
| 373 |
+
"retention": "reference",
|
| 374 |
+
"desc": "a kitchen"}],
|
| 375 |
+
"subjects": {}})
|
| 376 |
+
back = json.loads(PL._restore_rail_only(written, rail_mp))
|
| 377 |
+
by_tag = {r["tag"]: r for r in back["refs"]}
|
| 378 |
+
ck("a written register gets the rail's `mp` back",
|
| 379 |
+
by_tag["ref_1"].get("mp") == 0.54 and by_tag["ref_2"].get("mp") == 0.3,
|
| 380 |
+
f"got {by_tag['ref_1'].get('mp')!r} / {by_tag['ref_2'].get('mp')!r}")
|
| 381 |
+
ck("and a rail with no caps set changes nothing",
|
| 382 |
+
PL._restore_rail_only(written, [{"tag": "ref_1", "file": "cook_face.png"}])
|
| 383 |
+
== written)
|
| 384 |
+
|
| 385 |
+
# The rail is authoritative, not merely restorative. gemma4-26b read `mp`
|
| 386 |
+
# as pixels and returned 1000000000; it cleared the floor, survived into
|
| 387 |
+
# the register and filled the rail dropdown with zeroes.
|
| 388 |
+
junk = json.dumps({"refs": [{"tag": "ref_1", "file": "cook_face.png",
|
| 389 |
+
"subject": 1, "desc": "a face",
|
| 390 |
+
"mp": 1000000000}], "subjects": {}})
|
| 391 |
+
cleared = json.loads(PL._restore_rail_only(
|
| 392 |
+
junk, [{"tag": "ref_1", "file": "cook_face.png"}]))
|
| 393 |
+
ck("a model-authored `mp` is dropped when the rail has no cap",
|
| 394 |
+
"mp" not in cleared["refs"][0], f"got {cleared['refs'][0].get('mp')!r}")
|
| 395 |
+
overruled = json.loads(PL._restore_rail_only(junk, rail_mp))
|
| 396 |
+
ck("and overruled when the rail has one",
|
| 397 |
+
overruled["refs"][0].get("mp") == 0.54)
|
| 398 |
+
|
| 399 |
+
bad = json.dumps({"refs": [{"tag": "ref_1", "file": "cook_face.png",
|
| 400 |
+
"retention": "reference", "desc": "a face",
|
| 401 |
+
"mp": 1000000000}], "subjects": {}})
|
| 402 |
+
errs, _ = PL.validate(json.dumps(rail_shots), bad, hops=2, known_files=kf)
|
| 403 |
+
ck("and a register that keeps one is rejected, naming the units",
|
| 404 |
+
any("MEGApixels" in e for e in errs), "; ".join(errs)[:130])
|
| 405 |
+
|
| 406 |
kept = PL._merge_register(
|
| 407 |
json.dumps({"refs": [{"tag": "ref_1", "file": "a.jpg",
|
| 408 |
"desc": "a woman in green"}],
|
|
@@ -133,12 +133,14 @@ def build():
|
|
| 133 |
"the frame pin.",
|
| 134 |
},
|
| 135 |
"mp": {
|
| 136 |
-
"type": "number", "minimum": 0,
|
| 137 |
-
"description": "Per-reference pixel budget in
|
| 138 |
-
"
|
| 139 |
-
|
| 140 |
-
"
|
| 141 |
-
"
|
|
|
|
|
|
|
| 142 |
},
|
| 143 |
},
|
| 144 |
}
|
|
@@ -172,6 +174,15 @@ def build():
|
|
| 172 |
"in the ref_plan widget. Generated by tools/gen_schema.py from the "
|
| 173 |
"installed node -- do not edit by hand.",
|
| 174 |
"type": "object",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
"properties": {
|
| 176 |
"shot_plan": {
|
| 177 |
"type": "object",
|
|
|
|
| 133 |
"the frame pin.",
|
| 134 |
},
|
| 135 |
"mp": {
|
| 136 |
+
"type": "number", "minimum": 0, "maximum": r.REF_MP_MAX,
|
| 137 |
+
"description": "Per-reference pixel budget in MEGApixels, set "
|
| 138 |
+
"by the person on the rail. Do not author it: "
|
| 139 |
+
"omit the field. It is here so a hand-written "
|
| 140 |
+
"plan can carry one, and a written value is "
|
| 141 |
+
"discarded on the pinned path. A set value has "
|
| 142 |
+
f"a {r.REF_MP_MIN:g} MP floor and a "
|
| 143 |
+
f"{r.REF_MP_MAX:g} MP ceiling; 0 means no cap.",
|
| 144 |
},
|
| 145 |
},
|
| 146 |
}
|
|
|
|
| 174 |
"in the ref_plan widget. Generated by tools/gen_schema.py from the "
|
| 175 |
"installed node -- do not edit by hand.",
|
| 176 |
"type": "object",
|
| 177 |
+
# Both documents, or the grammar is not doing its job. Without this a
|
| 178 |
+
# reply of `{"shot_plan": {...}}` alone is schema-valid: observed live
|
| 179 |
+
# with gemma4-26b, which emitted no `ref_plan` on all three attempts.
|
| 180 |
+
# The register then stays empty, `_remap_pinned_tags` bails on the
|
| 181 |
+
# falsy ref_text before it can restore the rail's names, and every
|
| 182 |
+
# repair turn is told its beats cite undeclared tags -- so the model
|
| 183 |
+
# rewrites the beats it already got right and never emits the document
|
| 184 |
+
# that is actually missing. Qwen3 volunteered both and hid this.
|
| 185 |
+
"required": ["shot_plan", "ref_plan"],
|
| 186 |
"properties": {
|
| 187 |
"shot_plan": {
|
| 188 |
"type": "object",
|