Files
esh-pfi-infrastructure/services/semif-serve/spike/wyrd_cases.py
T
vh e268ff7c99 spike(semif): consumer fit for Wyrd scene change and Cicada affect gate (no service change)
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.
2026-09-27 09:20:35 -07:00

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))