Name every stat in the guide by the path the AI has to write

The referee refused `npc.<id>.status` on a character that has a status stat
under another name, and the refusal was right: the model was reaching for a
path the guide never gave it.

The guide is the fixed list of everything a scenario tracks, and it named
stats in prose. A player stat and a world stat both read as a bare name, and
an NPC's stats read as the display name plus the stat — "Trainer Milo
active_status". The model had to turn that back into `npc.milo.active_status`
itself, and in the Pokemon demo five other characters carry a stat called
`status`, so the path it built was `npc.milo.status`. The live values do state
the paths, but only for the NPCs a scene has mentioned, so an NPC off screen
was addressable only by guesswork.

Each line now leads with the path: `player.potions`, `npc.milo.active_status`,
`flags.sandstorm_active`. An NPC's header is written even when it has no
description, because it is the one line that ties a display name to its id.
`EMIT_RULE` points at the guide for paths rather than at the live values
alone.

A free-text stat is marked `(free text)` beside its path, which is the wording
`EMIT_RULE` already used to describe it — the guide had been writing "free
text" at the end of the line instead. Such a stat now also gets a line when it
has no description, where before it was dropped and the model was left to send
a number for a stat that holds a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017imUKVPhqophVUZZwJK7ST
This commit is contained in:
Claude
2026-08-30 06:46:12 +00:00
parent 41ed2f63b5
commit 91907a30bd
2 changed files with 74 additions and 15 deletions
+33 -12
View File
@@ -33,8 +33,9 @@ EMIT_RULE = (
"while a large change — or reaching a stat's minimum or maximum — is reserved for a " "while a large change — or reaching a stat's minimum or maximum — is reserved for a "
"genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a " "genuinely pivotal, defining moment (a passing remark shifts a relationship a little; a "
"lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of " "lasting act of loyalty or betrayal shifts it a lot). Do not move a value across most of "
'its range in a single ordinary turn. Use the paths exactly as shown in the world state: ' 'its range in a single ordinary turn. Every stat is listed by its exact path in the '
'"player.<stat>", ' 'stat guide and again beside its live value — copy a path from there rather than '
'building one out of a name. The shapes are "player.<stat>", '
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, ' '"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
'not the display name); "flags.<name>": true or false to toggle an on/off state; and ' 'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
'"milestones.<id>": true when an objective is completed. Some stats marked (free text) in ' '"milestones.<id>": true when an objective is completed. Some stats marked (free text) in '
@@ -854,20 +855,31 @@ def render_state_section(world_state: dict, stat_schema: dict,
def _describe_stat(name: str, d: dict) -> str | None: def _describe_stat(name: str, d: dict) -> str | None:
"""Returns one reference line for a stat. """Returns one reference line for a stat, named by the path the AI writes.
`name` is that path. The guide is the model's only complete list of what
exists, so a stat named any other way leaves it to work the path out from
the live values, and those cover only what is on screen this turn.
The description and the band ladder are independent, and each is included The description and the band ladder are independent, and each is included
only when present, so a stat may have either, both, or neither. only when present, so a stat may have either, both, or neither.
""" """
is_text = d.get("type") == "text"
# `EMIT_RULE` sends the model here to find out which stats take a whole
# value instead of a change, and it names this marker, so the two have to
# be written the same way.
label = f"{name} (free text)" if is_text else name
bits: list[str] = [] bits: list[str] = []
desc = d.get("desc") desc = d.get("desc")
if isinstance(desc, str) and desc.strip(): if isinstance(desc, str) and desc.strip():
# Fragments are joined with "; " and end with a single ".", so remove # Fragments are joined with "; " and end with a single ".", so remove
# any trailing period the author put on the description. # any trailing period the author put on the description.
bits.append(desc.strip().rstrip(".")) bits.append(desc.strip().rstrip("."))
if d.get("type") == "text": if is_text:
bits.append("free text") # Free text has no range and no bands, so the marker is the whole
return f"{name} — {'; '.join(bits)}." if bits else None # entry. It still earns a line without a description, because the
# marker is what stops the model sending a delta.
return f"{label} — {'; '.join(bits)}." if bits else f"{label}."
lo, hi = d.get("min"), d.get("max") lo, hi = d.get("min"), d.get("max")
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
bits.append(f"range {lo}–{hi}") bits.append(f"range {lo}–{hi}")
@@ -879,7 +891,7 @@ def _describe_stat(name: str, d: dict) -> str | None:
) )
if ladder: if ladder:
bits.append(f"bands: {ladder}") bits.append(f"bands: {ladder}")
return f"{name} — {'; '.join(bits)}." if bits else None return f"{label} — {'; '.join(bits)}." if bits else None
def render_reference(stat_schema: dict) -> str: def render_reference(stat_schema: dict) -> str:
@@ -893,7 +905,7 @@ def render_reference(stat_schema: dict) -> str:
for section in STAT_SECTIONS: for section in STAT_SECTIONS:
for name, d in (stat_schema.get(section) or {}).items(): for name, d in (stat_schema.get(section) or {}).items():
if isinstance(d, dict): if isinstance(d, dict):
row = _describe_stat(name, d) row = _describe_stat(f"{section}.{name}", d)
if row: if row:
lines.append(row) lines.append(row)
for npc_key, ndef in (stat_schema.get("npcs") or {}).items(): for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
@@ -901,18 +913,27 @@ def render_reference(stat_schema: dict) -> str:
continue continue
name = npc_name(ndef, npc_key) name = npc_name(ndef, npc_key)
desc = ndef.get("desc") desc = ndef.get("desc")
if isinstance(desc, str) and desc.strip(): # The header is written even for an NPC with no description, because it
lines.append(f"NPC {name} ({npc_key}) — {desc.strip().rstrip('.')}.") # is the only line that ties a display name to the id the AI has to
# address. The live values state it too, but only for the NPCs a scene
# has mentioned, so without this an NPC off screen can only be guessed
# at — and a guess is refused as a character or a stat that does not
# exist.
head = f"NPC {name} (npc.{npc_key})"
lines.append(
f"{head} — {desc.strip().rstrip('.')}."
if isinstance(desc, str) and desc.strip() else f"{head}."
)
for sname, sdef in (ndef.get("stats") or {}).items(): for sname, sdef in (ndef.get("stats") or {}).items():
if isinstance(sdef, dict): if isinstance(sdef, dict):
row = _describe_stat(f"{name} {sname}", sdef) row = _describe_stat(f"npc.{npc_key}.{sname}", sdef)
if row: if row:
lines.append(row) lines.append(row)
for name, d in (stat_schema.get("flags") or {}).items(): for name, d in (stat_schema.get("flags") or {}).items():
if isinstance(d, dict): if isinstance(d, dict):
desc = d.get("desc") desc = d.get("desc")
if isinstance(desc, str) and desc.strip(): if isinstance(desc, str) and desc.strip():
lines.append(f"{name} (flag) — {desc.strip().rstrip('.')}.") lines.append(f"flags.{name} — {desc.strip().rstrip('.')}.")
if not lines: if not lines:
return "" return ""
return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines) return "Stat guide (fixed reference):\n" + "\n".join(f"- {ln}" for ln in lines)
+41 -3
View File
@@ -209,10 +209,48 @@ def test_reference_includes_desc_and_bands_independently():
assert "very weak" in guide and "range 0–100" in guide assert "very weak" in guide and "range 0–100" in guide
# A counter like day has no desc or bands, so it contributes nothing # A counter like day has no desc or bands, so it contributes nothing
# here. Flags do show their desc. # here. Flags do show their desc.
assert "has_key (flag) — Holds the key." in guide assert "flags.has_key — Holds the key." in guide
# NPCs contribute their own description and per-NPC stat lines. # NPCs contribute their own description and per-NPC stat lines.
assert "NPC Gwen (gwen) — A loyal ranger." in guide assert "NPC Gwen (npc.gwen) — A loyal ranger." in guide
assert "Gwen trust" in guide and "The Drake ferocity" in guide assert "npc.gwen.trust" in guide and "npc.drake.ferocity" in guide
def test_reference_names_every_stat_by_its_path():
"""The guide is the model's only complete list of what exists, so it has
to name each stat the way a state block must name it. Naming a stat after
its owner's display name ("Trainer Milo active_status") left the model to
build the path itself, and the paths it built were refused as stats and
characters that do not exist."""
guide = w.render_reference(SCHEMA)
assert "player.hp" in guide
assert "player.outfit" in guide
# The old wording, which read as prose rather than as an address.
assert "Gwen trust" not in guide
assert "The Drake ferocity" not in guide
def test_reference_names_an_npc_that_has_no_description():
"""The live values name an NPC only while a scene mentions them, so the
guide is the only place an off-screen NPC's id is stated. The Drake has no
`desc`, and used to reach the model as "The Drake ferocity" alone."""
guide = w.render_reference(SCHEMA)
assert "NPC The Drake (npc.drake)." in guide
def test_reference_marks_free_text_stats_the_way_the_emit_rule_names_them():
"""`EMIT_RULE` tells the model that a stat "marked (free text) in the stat
guide" takes a whole value rather than a delta, so the guide has to carry
that marker literally."""
assert "(free text)" in w.EMIT_RULE
guide = w.render_reference(SCHEMA)
assert "player.outfit (free text) — What the player is wearing." in guide
def test_reference_lists_a_free_text_stat_with_no_description():
"""The marker alone is the entry. Dropping the line would leave the model
sending a number for a stat that holds a string."""
schema = {"player": {"holding": {"type": "text", "initial": ""}}}
assert "player.holding (free text)." in w.render_reference(schema)
def test_unknown_paths_rejected_not_fatal(): def test_unknown_paths_rejected_not_fatal():