Sandpies Claude Opus 5 commited on
Commit
7d825cf
·
1 Parent(s): fdac1d9

writer: the rail owns `file`, so stop rejecting plans over it

Browse files

Reported from a live 3-hop write that converged on attempt 3 of 3:

attempt 1: subject 1 is missing name, locked, context;
@hero_face must keep file 'gibsonlethal.webp', not
'hero_face.webp'; @hero_face needs desc
attempt 2: subject 1 is missing name, locked, context;
@hero_face needs desc; @hero_outfit needs desc
repair turn: desc and subject prose are required by the schema

The filename error cost two attempts, not one. `_only_register_prose_gaps`
fires the tightened-schema repair -- the one that removes the empty path from
the grammar, because a legal `"subjects": {}` is the cheapest completion and
repair prose does not outvote it -- only when EVERY error is a prose gap. A file
mismatch is not one, so attempt 1 got the weak generic turn and the repair that
works was held back a full round.

It was never the writer's field. The rail pins a tag to a picture, and validate
holds the right filename in `by_tag` while rejecting the plan for not having it
-- the same argument already written down for `mp` in `_restore_rail_only`. The
model is not even guessing here: it renames a real file to match the tag it was
handed. Tidy, and wrong.

`_restore_pinned_files` puts it back before validate sees it, keyed on a real
tag match, and prints what it changed. Deliberately NOT a RAIL_ONLY_FIELD: that
loop drops a field the rail cannot supply, which is right for `mp` and
destructive for `file`, because on a brief-only write the model's filename off
the folder listing is the only one there.

Verified against the reported errors verbatim: those three give the weak turn,
the same three minus the filename give the tightened one. validate keeps the
mismatch error as the backstop for hand-authored plans, and its test stays.

Six new tests; all 9 checks pass. DEVLOG section 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VB4Dw6F79643vySkf19KV

Files changed (3) hide show
  1. docs/DEVLOG.md +55 -0
  2. planner.py +55 -0
  3. tools/check_planner.py +25 -0
docs/DEVLOG.md CHANGED
@@ -1854,3 +1854,58 @@ and this does not.
1854
  The lesson is the older one, in a new place: a control that *displays* a value
1855
  it has not committed is worse than one that displays nothing. Section 31 caught
1856
  a lint that cried wolf; this is a dropdown that cried yes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1854
  The lesson is the older one, in a new place: a control that *displays* a value
1855
  it has not committed is worse than one that displays nothing. Section 31 caught
1856
  a lint that cried wolf; this is a dropdown that cried yes.
1857
+
1858
+ ## 35. The rail already had the answer (2026-09-02)
1859
+
1860
+ The Arch reporter from section 34, writing their first plan once the panel
1861
+ worked:
1862
+
1863
+ attempt 1: subject 1 is missing name, locked, context;
1864
+ @hero_face must keep file 'gibsonlethal.webp', not
1865
+ 'hero_face.webp'; @hero_face needs desc
1866
+ attempt 2: subject 1 is missing name, locked, context;
1867
+ @hero_face needs desc; @hero_outfit needs desc
1868
+ repair turn: desc and subject prose are required by the schema
1869
+ wrote a 3-hop plan in 3 attempt(s)
1870
+
1871
+ It converged, one attempt from failing. The interesting line is the middle one
1872
+ on attempt 1, and not for the reason it looks like.
1873
+
1874
+ `_only_register_prose_gaps` fires the tightened-schema repair -- the one that
1875
+ removes the empty path from the grammar, because section 31 established that
1876
+ while `"subjects": {}` is legal it is also the cheapest legal completion and no
1877
+ amount of repair prose outvotes it. That gate requires EVERY error to be a
1878
+ prose gap. A file mismatch is not one. So attempt 1 got the weak generic
1879
+ "change only what the errors name" turn, attempt 2 produced the same subject
1880
+ error again, and only then -- with the file error gone -- did the mechanism
1881
+ that actually works get to run.
1882
+
1883
+ **One misnamed file cost two attempts: its own, and the round it kept the real
1884
+ repair from firing in.**
1885
+
1886
+ It was never the model's field. The rail pins a tag to a picture, and
1887
+ `validate` is holding the correct filename in `by_tag` at the moment it rejects
1888
+ the plan for not having it. This is the argument already written down for `mp`
1889
+ in `_restore_rail_only` -- *spend an attempt on a rejection the rail already had
1890
+ the answer to* -- and `file` is a stronger case than `mp` ever was, because the
1891
+ model is not even guessing: it is renaming a real file to match the tag it was
1892
+ given. `gibsonlethal.webp` becomes `hero_face.webp`. Tidy, and wrong.
1893
+
1894
+ `_restore_pinned_files` now puts it back before validate sees it, keyed on a
1895
+ real tag match, and prints what it changed.
1896
+
1897
+ **Why it is not simply another `RAIL_ONLY_FIELD`.** That loop drops a field the
1898
+ rail cannot supply, which is exactly right for `mp` -- an invented megapixel cap
1899
+ is never wanted -- and destructive for `file`. On a brief-only write the rail is
1900
+ empty and the filename the model read off the folder listing is the only one
1901
+ there. The repair has to touch pinned rows and nothing else, which is the whole
1902
+ difference between restoring a field and owning one.
1903
+
1904
+ Checked against the reported errors verbatim: three errors give the weak turn,
1905
+ the same three minus the filename give the tightened one. The plan that took
1906
+ three attempts should now take two, and a rail whose filenames do not resemble
1907
+ their tags -- which is most rails, since photographs arrive named by the camera
1908
+ or the download -- stops being a hazard at all.
1909
+
1910
+ The older lesson underneath: every field the node can determine and chooses to
1911
+ reject instead is an attempt spent, and attempts are a budget of three.
planner.py CHANGED
@@ -948,6 +948,52 @@ def _restore_rail_only(ref_text, pinned):
948
  return json.dumps(obj, indent=2) if touched else ref_text
949
 
950
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
951
  def _remap_pinned_tags(shot_text, ref_text, pinned):
952
  """Rename invented tags back to the rail's, matching on `file`.
953
 
@@ -1212,6 +1258,15 @@ async def write_plan(brief, hops, *, complete_fn, files=None,
1212
  # Before validate, so a restored `mp` is what the lints and the
1213
  # returned plan both see, on the failure path as well as the ok one.
1214
  ref_text = _restore_rail_only(ref_text, pinned)
 
 
 
 
 
 
 
 
 
1215
  if not shot_text:
1216
  last_errors = ["the reply contained no JSON. Answer with the two "
1217
  "JSON blocks and nothing else."]
 
948
  return json.dumps(obj, indent=2) if touched else ref_text
949
 
950
 
951
+ def _restore_pinned_files(ref_text, pinned):
952
+ """Make the rail authoritative for `file` on the rows it pins.
953
+
954
+ -> (ref_text, {tag: filename}) for whatever had to be put back.
955
+
956
+ The rail pins a tag to a picture, so `file` is not the writer's field to
957
+ choose on a pinned row -- and `validate` is already holding the right value
958
+ in `by_tag` at the moment it rejects the plan for not having it. A model
959
+ that has just looked at the photograph names the file after the tag:
960
+ `gibsonlethal.webp` came back as `hero_face.webp`, which is tidy, and wrong.
961
+
962
+ That cost two attempts rather than one. `_only_register_prose_gaps` fires
963
+ the tightened-schema repair -- the one that takes the empty path out of the
964
+ grammar -- only when EVERY error is a prose gap, and a file mismatch is not
965
+ one. So the round that could have been repaired properly got the weak
966
+ generic turn instead, and the mechanism that works was delayed until the
967
+ next one. Live 2026-09-02: converged on attempt 3 of 3, one from failing.
968
+
969
+ Keyed on a real tag match ONLY, and deliberately NOT a `RAIL_ONLY_FIELD`:
970
+ that loop DROPS a field the rail cannot supply, which is correct for `mp`
971
+ and destructive here. On a brief-only write the rail is empty and the
972
+ model's filename, read off the folder listing, is the only one there.
973
+ """
974
+ obj = _parse_obj(ref_text)
975
+ if not isinstance(obj, dict):
976
+ return ref_text, {}
977
+ by_tag = {}
978
+ for p in (pinned or []):
979
+ if not isinstance(p, dict):
980
+ continue
981
+ tag = str(p.get("tag") or "").lstrip("@").strip()
982
+ fname = str(p.get("file") or "").strip()
983
+ if tag and fname:
984
+ by_tag[tag] = fname
985
+ fixed = {}
986
+ for r in (obj.get("refs") or []):
987
+ if not isinstance(r, dict):
988
+ continue
989
+ tag = str(r.get("tag") or "").lstrip("@").strip()
990
+ want = by_tag.get(tag)
991
+ if want and str(r.get("file") or "").strip() != want:
992
+ r["file"] = want
993
+ fixed[tag] = want
994
+ return (json.dumps(obj, indent=2) if fixed else ref_text), fixed
995
+
996
+
997
  def _remap_pinned_tags(shot_text, ref_text, pinned):
998
  """Rename invented tags back to the rail's, matching on `file`.
999
 
 
1258
  # Before validate, so a restored `mp` is what the lints and the
1259
  # returned plan both see, on the failure path as well as the ok one.
1260
  ref_text = _restore_rail_only(ref_text, pinned)
1261
+ # Same argument as `mp` above, and the same place to make it: the
1262
+ # rail owns `file` on a row it pins, so a renamed one is repaired
1263
+ # here rather than spending an attempt -- and, worse, blocking the
1264
+ # tightened-schema repair by not being a prose gap.
1265
+ ref_text, refiled = _restore_pinned_files(ref_text, pinned)
1266
+ if refiled:
1267
+ print(f"[{TAG}] rail filenames restored: "
1268
+ + ", ".join(f"@{t} -> {f}" for t, f in refiled.items()),
1269
+ flush=True)
1270
  if not shot_text:
1271
  last_errors = ["the reply contained no JSON. Answer with the two "
1272
  "JSON blocks and nothing else."]
tools/check_planner.py CHANGED
@@ -241,6 +241,31 @@ def main():
241
  ck("a pinned row cannot change file",
242
  any("cook_face.png" in e for e in errs), "; ".join(errs[:1]))
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  extra = json.loads(json.dumps(rail))
245
  extra["refs"].append({"tag": "hallway", "file": "apron.png"})
246
  errs, _ = PL.validate(json.dumps(rail_shots), json.dumps(extra),
 
241
  ck("a pinned row cannot change file",
242
  any("cook_face.png" in e for e in errs), "; ".join(errs[:1]))
243
 
244
+ # ...but the writer loop repairs it before validate ever sees it, because
245
+ # the rail already holds the answer. gemma renamed gibsonlethal.webp to
246
+ # hero_face.webp live on 2026-09-02: one attempt for the mismatch itself,
247
+ # and a second because a file error is not a prose gap, so it suppressed
248
+ # the tightened-schema repair for a whole round.
249
+ healed, refiled = PL._restore_pinned_files(json.dumps(stolen), pinned)
250
+ ck("the rail's filename is put back", refiled == {"ref_1": "cook_face.png"},
251
+ repr(refiled))
252
+ errs2, _ = PL.validate(json.dumps(rail_shots), healed,
253
+ hops=2, known_files=[p["file"] for p in pinned],
254
+ pinned=pinned)
255
+ ck("and the repaired plan then validates", not errs2, "; ".join(errs2[:2]))
256
+ ck("a register needing no repair is returned unchanged",
257
+ PL._restore_pinned_files(healed, pinned) == (healed, {}))
258
+
259
+ # NOT a RAIL_ONLY_FIELD. That loop drops what the rail cannot supply, which
260
+ # would delete the only filename a brief-only write has.
261
+ loose = json.dumps({"refs": [{"tag": "someone", "file": "found.png",
262
+ "subject": 1, "desc": "a face"}],
263
+ "subjects": {}})
264
+ ck("a brief-only write keeps the filename the model chose",
265
+ PL._restore_pinned_files(loose, []) == (loose, {}))
266
+ ck("and so does a tag the rail does not pin",
267
+ PL._restore_pinned_files(loose, pinned) == (loose, {}))
268
+
269
  extra = json.loads(json.dumps(rail))
270
  extra["refs"].append({"tag": "hallway", "file": "apron.png"})
271
  errs, _ = PL.validate(json.dumps(rail_shots), json.dumps(extra),