writer: the rail owns `file`, so stop rejecting plans over it
Browse filesReported 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
- docs/DEVLOG.md +55 -0
- planner.py +55 -0
- tools/check_planner.py +25 -0
|
@@ -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.
|
|
@@ -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."]
|
|
@@ -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),
|