Harness consumer_fit.py runs a consumer's per-turn decisions over hand-labelled cases, with rotations, a content-free null control and tagged positive controls. Cicada: an input-only 'does this earn a visible reaction?' gate scored 30/31 with descriptive options and 19/31 with terse yes/no options. Scoped by Cicada's 2026-09-20 ruling (affect is emitted once, no mood-ring classifier). Wyrd: on 3 real seed graphs, the first place-change wording failed its positive controls (1/6 moves). A location-anchored rewording scored 21/21, and exit selection scored 18/21. Semif fits the choice, not writing the node.
214 lines
15 KiB
Python
214 lines
15 KiB
Python
"""Wyrd scene-change spike (2026-09-27): builds consumer_fit.py scenario files.
|
|
|
|
Today Wyrd decides a scene change inside the GM's big `gen` structured-output calls, two ways
|
|
(wyrd backend/wyrd_backend/gm/gen_proposer.py, orchestrator.py, story_direction.py):
|
|
R8 reflect's plot_direction.target_node, accepted only if it is an advanceable exit
|
|
(plot_direction.intrude is advisory and never read)
|
|
path B raw AddStoryNode + AdvanceToNode events, gated by nothing but prose: "Add + advance to
|
|
a NEW node ONLY when the scene moves to a genuinely different place — never every turn
|
|
(that would churn the backdrop)." archival-memory "Watch: GM story-node churn".
|
|
The two semif decisions that would stand in for those, per turn:
|
|
place has the scene moved to a genuinely different place? (the path-B gate, Wyrd's own rule)
|
|
exit which advanceable exit, if any, does this turn move into? (R8 target selection;
|
|
options = "stay" + the real exits, so semif can only ever name a legal target)
|
|
Semif cannot WRITE the new node (title, summary, mood) or the steering directive; the GM keeps
|
|
that. It only makes the typed choice.
|
|
|
|
Story graphs are real seeds from wyrd backend/.wyrd-data/canon (read immutable): The Fletcher
|
|
(3de8f7da916d), The Toll Road (8ad5ebeae45f), The Last Vigil (8c3da0e2915a; that campaign had
|
|
ZERO edges, so every real scene change went through path B; one edge is added here so R8 has
|
|
something to choose). The turn texts are authored in the campaigns' own register (their opening
|
|
narration is real); the transcripts themselves are gone from conv-api (session_not_found).
|
|
Labels are ONE labeller's judgment (mine). "control" cases are unambiguous.
|
|
python wyrd_cases.py # writes consumer-fit-2026-09-27/wyrd-scene-{fletcher,toll,vigil}.json
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
GRAPHS = {
|
|
"fletcher": {
|
|
"current": {"title": "The Idle Wait", "summary": "You have no contracts, no coin, and nowhere to be. "
|
|
"The town is a pressure cooker.", "location": "Oakhaven crossroads"},
|
|
"exits": [("meet_wren", "Approach Wren", "Meeting the Fletcher",
|
|
"You find Wren working on arrows. She has a soldier's eyes."),
|
|
("tavern", "Go to Tavern", "Tavern Noise",
|
|
"The local tavern is loud with soldiers from both sides.")],
|
|
},
|
|
"toll": {
|
|
"current": {"title": "The First Toll", "summary": "You arrive at the tollhouse in the pouring rain, "
|
|
"presenting your sealed letter to Sera.", "location": "north road tollhouse"},
|
|
"exits": [("departure", "Passage Granted/Denied", "Beyond the Toll",
|
|
"You leave the tollhouse, either with or without Sera's blessing, continuing down the north road.")],
|
|
},
|
|
"vigil": {
|
|
"current": {"title": "The Last Vigil", "summary": "The player stands in the inner sanctum, guarding the "
|
|
"Book. Lynnae enters through a hidden alcove to discuss the encroaching demons.",
|
|
"location": "sanctum interior"},
|
|
"exits": [("outside", "Leave the sanctum", "Outside the Sanctum",
|
|
"Lynnae and the player step out of the interior into the exterior surroundings.")],
|
|
},
|
|
}
|
|
|
|
# (id, graph, tag, player action, character reply, place label, exit label)
|
|
CASES = [
|
|
# ---------------- The Fletcher: current = The Idle Wait, at the crossroads.
|
|
("f-idle-look", "fletcher", "control", "I lean on the fence post and watch the soldiers pass.",
|
|
"Boots churn the mud as a column of Iron Legion pikemen tramps past the crossroads, eyes forward, "
|
|
"drums muffled by the damp. Nobody spares you a glance. Across the square, a Sylvan Pact scout watches "
|
|
"them go from the shadow of the well, and does not move until the last of them has passed.",
|
|
"holds", "stay"),
|
|
("f-plan-tavern", "fletcher", "case", "Maybe I'll try the tavern later, see if anyone's hiring.",
|
|
"A gust carries the smell of woodsmoke and spilled ale from the tavern at the far end of the square. "
|
|
"Someone inside is laughing too loudly. It would be warm in there, and full of men with coin and "
|
|
"grudges. For now the crossroads holds you, and the rain has not yet decided whether to fall.",
|
|
"holds", "stay"),
|
|
("f-go-tavern", "fletcher", "control", "I head for the tavern and push the door open.",
|
|
"The door gives with a groan and the noise hits you like heat: Legion sergeants at one long table, "
|
|
"Pact rangers at another, and a narrow no-man's-land of spilled ale between them. The barkeep looks "
|
|
"up, measures your empty belt, and goes back to wiping a cup that will never be clean.",
|
|
"moved", "tavern"),
|
|
("f-approach-wren", "fletcher", "control", "I walk over to the woman fletching arrows by the smithy.",
|
|
"She doesn't look up as your shadow falls across her work. Her hands keep moving, binding grey goose "
|
|
"feather to shaft with quick, sure turns. \"These are war arrows,\" she says, \"fletched for distance "
|
|
"and accuracy, not for some merchant's target practice. You're either very brave or very stupid.\"",
|
|
"holds", "meet_wren"),
|
|
("f-memory", "fletcher", "case", "I think back to the last time I held a bow, at the siege of Varn.",
|
|
"The memory comes unbidden: the walls of Varn black against a burning sky, the bowstring biting your "
|
|
"fingers, the screaming from the breach. You flex your hand now, in the mud of Oakhaven, and find it "
|
|
"still remembers the draw. Nobody at the crossroads notices a ghost remembering a war.",
|
|
"holds", "stay"),
|
|
("f-leave-town", "fletcher", "case", "Forget this town. I take the west road out of Oakhaven.",
|
|
"The crossroads falls behind you, then the last hovels, then the smell of the town itself. By "
|
|
"afternoon the west road narrows between dark pines, and the only sound is your own boots and, "
|
|
"somewhere ahead, the slow creak of a mill wheel turning in water you cannot yet see.",
|
|
"moved", "stay"),
|
|
("f-nightfall", "fletcher", "case", "I stay put and wait for something to happen.",
|
|
"Hours pass. The rain finally commits, then relents. Night settles over the crossroads, and one by "
|
|
"one the shutters close. A lantern is lit at the well. Still nothing. Still waiting. Somewhere a dog "
|
|
"barks twice and thinks better of it.",
|
|
"holds", "stay"),
|
|
("f-wren-comes", "fletcher", "case", "I keep watching the square.",
|
|
"A woman with a quiver of fresh-fletched arrows crosses the square toward you, stepping neatly around "
|
|
"the worst of the mud. She stops an arm's length away and studies you the way a quartermaster studies "
|
|
"a horse. \"You've been standing there all morning,\" Wren says. \"Either you're waiting for work, or "
|
|
"you're a spy. Which is it?\"",
|
|
"holds", "meet_wren"),
|
|
# ---------------- The Toll Road: current = The First Toll, in the tollhouse.
|
|
("t-reads-letter", "toll", "control", "I hand Sera the sealed letter.",
|
|
"Sera's gloved hand emerges from the shadow to take the letter, her fingers tracing the wax seal with "
|
|
"practiced efficiency. She turns it over slowly, examining it by the light of the single lamp. Rain "
|
|
"hammers the iron roof. \"Whose seal is this?\" she asks, without looking up.",
|
|
"holds", "stay"),
|
|
("t-questions", "toll", "case", "\"A friend in the capital. That's all you need to know.\"",
|
|
"\"That's rarely all I need to know.\" Sera sets the letter on the counter between you and leaves her "
|
|
"hand on it. \"The road north has been kind to liars lately. It won't stay that way.\" Water drips from "
|
|
"the eaves into a bucket by the door, counting out the silence.",
|
|
"holds", "stay"),
|
|
("t-future", "toll", "case", "\"Stamp it and I'll be out of your hair before the rain stops.\"",
|
|
"\"Before the rain stops,\" Sera repeats, as if tasting the words. \"It hasn't stopped in nine days.\" "
|
|
"She doesn't reach for the stamp. She reaches for the ledger instead, and opens it to a page thick with "
|
|
"names, and runs one gloved finger down the column.",
|
|
"holds", "stay"),
|
|
("t-granted", "toll", "control", "I wait while she decides.",
|
|
"The stamp comes down with a crack like a gavel. Sera slides the letter back and jerks her chin at the "
|
|
"door. You step out into the downpour, and the tollhouse lamp shrinks behind you as the north road "
|
|
"unspools ahead, black and glistening, into the hills.",
|
|
"moved", "departure"),
|
|
("t-denied", "toll", "case", "\"Then I'll find another way north.\" I turn to go.",
|
|
"\"There is no other way north,\" Sera says to your back, but she lets you go. The door bangs shut "
|
|
"behind you. You walk on anyway, past the barrier, the rain soaking through your collar, the tollhouse "
|
|
"dwindling to a single yellow window behind you on the north road.",
|
|
"moved", "departure"),
|
|
("t-rider", "toll", "case", "I stay by the stove to dry off.",
|
|
"Hooves on the road outside, then a fist on the door. A rider in a sodden Legion cloak ducks in out of "
|
|
"the rain, glances at you, glances at Sera, and drops a heavier purse on the counter than any toll "
|
|
"requires. Sera's hand closes over it without a word.",
|
|
"holds", "stay"),
|
|
("t-city-memory", "toll", "case", "\"You've never been to the capital, have you?\"",
|
|
"Something shifts behind Sera's eyes. \"I was born there,\" she says. \"Under the bell tower, in the "
|
|
"year the river froze. I left the day they hanged my father in the square.\" She says it flatly, the "
|
|
"way you'd read out a toll schedule, and turns back to the ledger.",
|
|
"holds", "stay"),
|
|
# ---------------- The Last Vigil: current = the inner sanctum.
|
|
("v-talk", "vigil", "control", "\"How close are they?\"",
|
|
"Lynnae rests her fingertips on the edge of the altar, not quite touching the Book. \"Close enough that "
|
|
"the wards are singing,\" she says. \"Listen.\" You do, and beneath the wind there is a high, thin note, "
|
|
"like a wet finger on the rim of a glass. The candles lean toward the door.",
|
|
"holds", "stay"),
|
|
("v-step-out", "vigil", "control", "\"Show me.\" I follow her out through the great doors.",
|
|
"The doors groan open and the wind takes the candle flames behind you. Outside, the sanctum steps drop "
|
|
"away into a valley of ash, and on the far ridge a line of red lights moves slowly, patiently, like a "
|
|
"procession of lanterns that has forgotten what it is mourning.",
|
|
"moved", "outside"),
|
|
("v-plan-pub", "vigil", "case", "\"If the wards fall, we take the Book to the old pub in the village.\"",
|
|
"\"The Drowned Crown?\" Lynnae almost smiles. \"No demon would look for holy relics behind a bar.\" She "
|
|
"turns the idea over, then nods slowly. \"If it comes to that. But the wards have not fallen yet, and I "
|
|
"would rather they never did.\"",
|
|
"holds", "stay"),
|
|
("v-to-pub", "vigil", "case", "I take the Book and we run for the village pub.",
|
|
"You run through the ash with the Book clutched to your chest, Lynnae a step behind. The heavy door "
|
|
"creaks on rusted hinges as you cross the threshold, the air inside the tavern thick with the scent of "
|
|
"stale ale, damp rot, and cold ash. You set the Book down on the bar.",
|
|
"moved", "stay"),
|
|
("v-vision", "vigil", "case", "I touch the Book's cover.",
|
|
"The world tilts. For one breath you are elsewhere: a burning library, a hundred years ago, monks "
|
|
"carrying armfuls of scrolls through smoke. Then the vision snaps shut like a book, and you are back in "
|
|
"the sanctum with Lynnae's hand hard on your wrist. \"Don't,\" she says. \"Not yet.\"",
|
|
"holds", "stay"),
|
|
("v-demon-enters", "vigil", "case", "I draw my blade and stand before the altar.",
|
|
"The great doors burst inward. Ash rolls across the floor, and in the middle of it something tall "
|
|
"unfolds itself, too many joints, a crown of red light. The candles gutter out all at once. Lynnae "
|
|
"is already chanting, her voice steady, her hands trembling.",
|
|
"holds", "stay"),
|
|
]
|
|
|
|
|
|
def build():
|
|
cases = []
|
|
for cid, graph, tag, action, reply, place, exit_ in CASES:
|
|
g = GRAPHS[graph]
|
|
exit_opts = [{"id": "stay", "description": f"None of these yet. The current scene holds: {g['current']['title']}."}]
|
|
exit_opts += [{"id": eid, "description": f"{label}: {title}. {summary}"} for eid, label, title, summary in g["exits"]]
|
|
cases.append({"id": cid, "tag": tag, "graph": graph,
|
|
"state": {"current_scene": g["current"], "player_action": action, "narration": reply},
|
|
"labels": {"place": place, "exit": exit_,
|
|
"place2": {"holds": "same", "moved": "elsewhere"}[place], "exit2": exit_}})
|
|
return cases
|
|
|
|
|
|
# Round 2 wordings, written AFTER round 1 failed its positive controls on `place` (5 of 6 real
|
|
# moves read as "holds", no better than the blind null). Both rounds are reported; round 1 is
|
|
# kept in the same request so the comparison is paired.
|
|
def round2(g):
|
|
loc, title = g["current"]["location"], g["current"]["title"]
|
|
place2 = {"id": "place2", "question": f"This is one turn of an interactive story. The turn began at the "
|
|
f"{loc}. By the end of this turn's narration, where are the characters?",
|
|
"options": [{"id": "same", "description": f"Still at the {loc}."},
|
|
{"id": "elsewhere", "description": f"Somewhere else: they have left the {loc} for a different place."}]}
|
|
exit2 = {"id": "exit2", "question": "This is one turn of an interactive story. The story can move on from the "
|
|
"current scene into one of the listed next scenes, but only once the narration actually gets there. "
|
|
"Where does this turn leave the story?",
|
|
"options": [{"id": "stay", "description": f"Still in the current scene, {title}: the narration has "
|
|
"not reached any next scene yet."}]
|
|
+ [{"id": eid, "description": f"Moved on to {t}: {summ}"} for eid, _, t, summ in g["exits"]]}
|
|
return [place2, exit2]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
here = Path(__file__).parent
|
|
place = {"id": "place", "question": "This is one turn of an interactive story. Has the scene moved to a "
|
|
"genuinely different place during this turn, or does the current scene still hold?",
|
|
"options": [{"id": "moved", "description": "The scene moved to a genuinely different place."},
|
|
{"id": "holds", "description": "The current scene still holds."}]}
|
|
exit_q = ("This is one turn of an interactive story. The story can advance from the current scene into "
|
|
"one of the listed next scenes. Which one does this turn move the story into?")
|
|
# exit options differ per graph, so one scenario file per graph, each carrying every decision
|
|
for graph, g in GRAPHS.items():
|
|
cases = [c for c in build() if c["graph"] == graph]
|
|
exit_opts = [{"id": "stay", "description": f"None of these yet. The current scene holds: {g['current']['title']}."}]
|
|
exit_opts += [{"id": eid, "description": f"{label}: {title}. {summary}"} for eid, label, title, summary in g["exits"]]
|
|
scen = {"name": f"wyrd-scene-{graph}",
|
|
"decisions": [place, {"id": "exit", "question": exit_q, "options": exit_opts}] + round2(g),
|
|
"cases": [{k: v for k, v in c.items() if k != "graph"} for c in cases]}
|
|
(here / "consumer-fit-2026-09-27" / f"wyrd-scene-{graph}.json").write_text(json.dumps(scen, indent=1))
|