Sandpies Claude Opus 5 commited on
Commit
5d67553
·
1 Parent(s): c1afce9

writer: the rail owns mp, and the schema requires both documents

Browse files

Two 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 CHANGED
@@ -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
- const mp = select(REF_MP.map(String), r.mp ? String(r.mp) : "", (v) => {
 
 
 
 
 
 
 
 
 
 
391
  r.mp = v ? Number(v) : 0;
392
  commit();
393
  }, {
394
  blankLabel: "full",
395
- titles: Object.fromEntries(REF_MP.map((v) =>
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");
js/editor/writer_bar.js CHANGED
@@ -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
 
planner.py CHANGED
@@ -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."]
prompt_pack/SCHEMA.json CHANGED
@@ -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
- "description": "Per-reference pixel budget in megapixels. 0 (or omit) means no cap. A set value has a 0.3 MP floor -- this is a token dial, so a location plate need not cost what a face costs."
 
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
  },
refs.py CHANGED
@@ -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; there is no
52
- # ceiling, because H3 only ever scales a reference DOWN
53
- # (`min(1.0, ...)` in nodes_minimax_h3.py) and a cap above the file's own size
54
- # would be a dial wired to nothing.
 
 
 
 
 
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:
routes.py CHANGED
@@ -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
tools/check_planner.py CHANGED
@@ -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"}],
tools/gen_schema.py CHANGED
@@ -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 megapixels. 0 "
138
- "(or omit) means no cap. A set value has a "
139
- f"{r.REF_MP_MIN:g} MP floor -- this is a token "
140
- "dial, so a location plate need not cost what "
141
- "a face costs.",
 
 
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",