Ghostlight/tools/run_qwen_ink_sequential_generation.py
2026-05-02 04:48:33 +01:00

1107 lines
46 KiB
Python

"""Run a sequential Qwen Ink generation prototype.
Pass 1: generate Maer choices from Maer-local awareness only.
Pass 2: generate participant appraisal/consolidation for the selected
observable event.
Pass 3: generate the next actor's action from updated local state.
"""
from __future__ import annotations
import argparse
import json
import re
import urllib.request
from pathlib import Path
from typing import Any
from project_local_context import build_projected_context
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_ENDPOINT = "http://192.168.1.84:11434/api/chat"
DEFAULT_FIXTURE = ROOT / "examples" / "agent-state.cold-wake-story-lab.json"
DEFAULT_ANNOTATION = ROOT / "examples" / "ink" / "cold-wake-sanctuary-intake.training.json"
DEFAULT_OUT_DIR = ROOT / "experiments" / "ink"
VALID_ACTION_TYPES = {
"speak",
"silence",
"move",
"gesture",
"touch_object",
"block_object",
"use_object",
"show_object",
"withhold_object",
"transfer_object",
"spend_resource",
"attack",
"wait",
"mixed",
}
VALID_RELATIONSHIP_IDS = {"rel-sella-maer", "rel-maer-sella"}
VALID_RELATIONSHIP_DELTA_PATHS = {
"trust",
"respect",
"resentment",
"dependence",
"fear",
"fascination",
"obligation",
"envy",
"moral_disgust",
"perceived_status_gap",
"expectation_of_care",
"expectation_of_betrayal",
}
VALID_SELLA_STATE_DELTA_PATHS = {
"canonical_state.behavioral_dimensions.control_pressure.current_activation",
"canonical_state.behavioral_dimensions.suspicion.current_activation",
"canonical_state.behavioral_dimensions.anxiety.current_activation",
"canonical_state.behavioral_dimensions.withdrawal.current_activation",
"canonical_state.behavioral_dimensions.attachment_seeking.current_activation",
"canonical_state.behavioral_dimensions.interpersonal_warmth.current_activation",
"canonical_state.situational_state.scarcity_pressure.current_activation",
"canonical_state.situational_state.exhaustion.current_activation",
"canonical_state.situational_state.overstimulation.current_activation",
"canonical_state.presentation_strategy.competence_theater.current_activation",
"canonical_state.presentation_strategy.moral_theater.current_activation",
}
def string_array_schema(description: str) -> dict[str, Any]:
return {"type": "array", "description": description, "items": {"type": "string"}}
def action_type_schema() -> dict[str, Any]:
return {"type": "string", "enum": sorted(VALID_ACTION_TYPES)}
def maer_choices_tool() -> dict[str, Any]:
choice_schema = {
"type": "object",
"required": [
"branch_id",
"ink_path",
"choice_text",
"action_type",
"actor_intent_private",
"observable_action",
"spoken_text",
"state_basis",
"expected_consequence_surfaces",
"training_hooks",
],
"properties": {
"branch_id": {"type": "string"},
"ink_path": {"type": "string"},
"choice_text": {"type": "string"},
"action_type": action_type_schema(),
"actor_intent_private": {"type": "string"},
"observable_action": {"type": "string"},
"spoken_text": {"type": "string"},
"state_basis": {"type": "string"},
"expected_consequence_surfaces": string_array_schema("Possible effects if this choice is selected."),
"training_hooks": string_array_schema("Semantic labels, not branch ids."),
},
}
return {
"type": "function",
"function": {
"name": "record_maer_choices",
"description": "Record four Maer-local candidate choices for the current scene.",
"parameters": {
"type": "object",
"required": ["choices"],
"properties": {
"choices": {
"type": "array",
"minItems": 4,
"maxItems": 4,
"items": choice_schema,
}
},
},
},
}
def sella_appraisal_tool() -> dict[str, Any]:
delta_schema = {
"type": "object",
"required": ["path", "delta", "rationale"],
"properties": {
"path": {"type": "string", "enum": sorted(VALID_SELLA_STATE_DELTA_PATHS)},
"delta": {"type": "number"},
"rationale": {"type": "string"},
},
}
relationship_delta_schema = {
"type": "object",
"required": ["relationship_id", "path", "delta", "rationale"],
"properties": {
"relationship_id": {"type": "string", "enum": ["rel-sella-maer"]},
"path": {"type": "string", "enum": sorted(VALID_RELATIONSHIP_DELTA_PATHS)},
"delta": {"type": "number"},
"rationale": {"type": "string"},
},
}
return {
"type": "function",
"function": {
"name": "record_sella_appraisal",
"description": "Record Sella's participant appraisal and proposed state consolidation for the observed Maer event.",
"parameters": {
"type": "object",
"required": [
"responder_agent_id",
"private_interpretation",
"attributed_motive",
"misread_risk",
"immediate_state_deltas",
"relationship_deltas",
"belief_updates",
"response_constraints",
],
"properties": {
"responder_agent_id": {"type": "string", "enum": ["sella_ren"]},
"private_interpretation": {"type": "string"},
"attributed_motive": {"type": "string"},
"misread_risk": {"type": "string"},
"immediate_state_deltas": {"type": "array", "items": delta_schema},
"relationship_deltas": {"type": "array", "items": relationship_delta_schema},
"belief_updates": string_array_schema("Belief changes or reinforced beliefs caused by the event."),
"response_constraints": string_array_schema("Constraints that should shape the next action."),
},
},
},
}
def sella_next_action_tool() -> dict[str, Any]:
response_schema = {
"type": "object",
"required": [
"responder_agent_id",
"action_type",
"observable_response",
"spoken_text",
"sella_private_interpretation",
"sella_attributed_motive",
"misread_risk",
"state_basis",
"reviewed_consequences",
"training_hooks",
],
"properties": {
"responder_agent_id": {"type": "string", "enum": ["sella_ren"]},
"action_type": action_type_schema(),
"observable_response": {"type": "string"},
"spoken_text": {"type": "string"},
"sella_private_interpretation": {"type": "string"},
"sella_attributed_motive": {"type": "string"},
"misread_risk": {"type": "string"},
"state_basis": {"type": "string"},
"reviewed_consequences": {"type": "string"},
"training_hooks": string_array_schema("Semantic labels for training."),
},
}
return {
"type": "function",
"function": {
"name": "record_sella_next_action",
"description": "Record Sella's next action from updated local state.",
"parameters": {
"type": "object",
"required": ["response"],
"properties": {"response": response_schema},
},
},
}
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def find_agent(fixture: dict[str, Any], agent_id: str) -> dict[str, Any]:
return next(agent for agent in fixture["agents"] if agent["agent_id"] == agent_id)
def find_scene(fixture: dict[str, Any], scene_id: str) -> dict[str, Any]:
return next(scene for scene in fixture["scenes"] if scene["scene_id"] == scene_id)
def find_pack(scene: dict[str, Any], speaker_id: str) -> dict[str, Any]:
return next(pack for pack in scene["dialogue_context_packs"] if pack["speaker_agent_id"] == speaker_id)
def find_relationship(fixture: dict[str, Any], source_id: str, target_id: str) -> dict[str, Any]:
return next(rel for rel in fixture["relationships"] if rel["source_id"] == source_id and rel["target_id"] == target_id)
def memory_summaries(agent: dict[str, Any], memory_ids: list[str]) -> list[str]:
summaries: list[str] = []
for group in agent["memories"].values():
for memory in group:
if memory["memory_id"] in memory_ids:
summaries.append(f"{memory['memory_id']}: {memory['summary']}")
return summaries
def goal_summaries(agent: dict[str, Any], goal_ids: list[str]) -> list[str]:
return [
f"{goal['goal_id']}: {goal['description']} Priority {goal['priority']}. Stake: {goal['emotional_stake']}"
for goal in agent["goals"]
if goal["goal_id"] in goal_ids
]
def post_json(endpoint: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
def extract_json_object(text: str) -> Any | None:
stripped = text.strip()
if stripped.startswith("{"):
return json.loads(stripped)
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, re.DOTALL)
if fenced:
return json.loads(fenced.group(1))
start = stripped.find("{")
end = stripped.rfind("}")
if start != -1 and end != -1 and end > start:
return json.loads(stripped[start : end + 1])
return None
def extract_tool_arguments(raw: dict[str, Any], expected_tool_name: str) -> tuple[Any | None, list[str]]:
message = raw.get("message", {})
tool_calls = message.get("tool_calls") or []
notes: list[str] = []
if not tool_calls:
return None, ["model returned no tool_calls"]
first_call = tool_calls[0]
function = first_call.get("function", {})
name = function.get("name")
if name != expected_tool_name:
notes.append(f"expected tool {expected_tool_name}, got {name}")
arguments = function.get("arguments")
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError as exc:
return None, notes + [f"tool arguments parse error: {exc}"]
if not isinstance(arguments, dict):
return None, notes + ["tool arguments were not an object"]
arguments, repair_notes = repair_tool_arguments(arguments)
notes.extend(repair_notes)
return arguments, notes
def repair_tool_arguments(arguments: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
repaired = dict(arguments)
notes: list[str] = []
for key in ["choices", "response", "appraisal"]:
value = repaired.get(key)
if isinstance(value, str):
parsed = parse_jsonish_tool_string(value)
if parsed is not None:
repaired[key] = parsed
notes.append(f"repaired stringified tool argument: {key}")
else:
notes.append(f"could not repair stringified tool argument: {key}")
return repaired, notes
def parse_jsonish_tool_string(value: str) -> Any | None:
candidate = value.strip()
candidates = [candidate]
if candidate.startswith("[") and not candidate.endswith("]"):
candidates.append(candidate + "]")
if candidate.startswith("{") and not candidate.endswith("}"):
candidates.append(candidate + "}")
if candidate.endswith(","):
candidates.append(candidate[:-1])
for item in candidates:
try:
return json.loads(item)
except json.JSONDecodeError:
continue
return None
def repair_nested_list_fields(payload: Any, fields: list[str]) -> tuple[Any, list[str]]:
notes: list[str] = []
if not isinstance(payload, dict):
return payload, notes
repaired = dict(payload)
for field in fields:
value = repaired.get(field)
if not isinstance(value, str):
continue
candidate = value.strip()
if candidate.endswith(","):
candidate = candidate[:-1]
parsed = parse_jsonish_tool_string(candidate)
if parsed is None:
notes.append(f"could not repair stringified nested list field: {field}")
continue
if isinstance(parsed, list):
repaired[field] = parsed
notes.append(f"repaired stringified nested list field: {field}")
return repaired, notes
def normalize_appraisal_payload(parsed: Any) -> tuple[Any, list[str]]:
notes: list[str] = []
if not isinstance(parsed, dict):
return parsed, notes
if isinstance(parsed.get("appraisal"), dict):
appraisal = parsed["appraisal"]
elif "responder_agent_id" in parsed:
appraisal = parsed
else:
return parsed, notes
appraisal, nested_repair_notes = repair_nested_list_fields(
appraisal,
["belief_updates", "response_constraints"],
)
notes.extend(nested_repair_notes)
return {"appraisal": appraisal}, notes
def fatal_notes(notes: list[str]) -> list[str]:
return [note for note in notes if not note.startswith("repaired ")]
def repair_notes(notes: list[str]) -> list[str]:
return [note for note in notes if note.startswith("repaired ")]
def request_qwen_tool(
endpoint: str,
model: str,
prompt: str,
tool: dict[str, Any],
expected_tool_name: str,
temperature: float,
top_p: float,
num_ctx: int,
think: bool,
timeout: int,
) -> tuple[dict[str, Any], str, Any, list[str]]:
request_payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"tools": [tool],
"stream": False,
"think": think,
"keep_alive": "10m",
"options": {"num_ctx": num_ctx, "temperature": temperature, "top_p": top_p},
}
try:
raw = post_json(endpoint, request_payload, timeout)
except Exception as exc:
raw = {"model": model, "error": repr(exc)}
response_text = json.dumps(
{
"content": "",
"thinking": None,
"tool_calls": [],
"error": repr(exc),
},
indent=2,
ensure_ascii=False,
)
return raw, response_text, None, [f"request failed for {expected_tool_name}: {exc!r}"]
message = raw.get("message", {})
response_text = json.dumps(
{
"content": message.get("content", ""),
"thinking": message.get("thinking"),
"tool_calls": message.get("tool_calls", []),
},
indent=2,
ensure_ascii=False,
)
parsed, notes = extract_tool_arguments(raw, expected_tool_name)
return raw, response_text, parsed, notes
def observable_action_payload(actor_id: str, selected_choice: dict[str, Any]) -> dict[str, Any]:
return {
"actor_id": actor_id,
"action_type": selected_choice["action_type"],
"observable_action": selected_choice["observable_action"],
"spoken_text": selected_choice.get("spoken_text", ""),
}
def build_maer_prompt(fixture: dict[str, Any], annotation: dict[str, Any]) -> str:
scene_id = annotation["scene_id"]
actor_id = annotation["protagonist_agent_id"]
projected = build_projected_context(fixture, annotation, actor_id, "choice_generation")
payload = {
"task": "Generate candidate player choices for Maer only. Do not write Sella's response.",
"output_contract": {
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
"root_keys": ["choices"],
"choice_count": 4,
"valid_action_types": sorted(VALID_ACTION_TYPES),
"choice_fields": [
"branch_id",
"ink_path",
"choice_text",
"action_type",
"actor_intent_private",
"observable_action",
"spoken_text",
"state_basis",
"expected_consequence_surfaces",
"training_hooks",
],
},
"hard_rules": [
"Use only Maer's local awareness and scene-observable facts.",
"Do not stringify nested arrays or objects inside tool arguments.",
"Do not include Sella's private state, private motives, or future response.",
"Do not resolve whether the corrupted packet contains a person.",
"Maer is a cetacean Navigator: do not describe him standing, walking, stepping, leaning, using hands, using pockets, or blocking a doorway.",
"Maer's physical actions should use shared clinic systems, mixed wet/dry work surfaces, displays, acoustic or translation systems, pressure fields, haptic waterflow, neural interfaces, or delegated crew action.",
"Do not describe ledger backing as a property of the packet. The packet can be shown, withheld, summarized, scanned, or rendered through clinic systems; route-ledger backing is a separate resource offer.",
"Include at least two non-speech choices.",
f"Use action_type values only from this exact enum: {', '.join(sorted(VALID_ACTION_TYPES))}.",
"The selected branch must be reducible to observable action for Sella.",
"Use semantic training hooks, not branch ids.",
],
"projected_local_context": projected["context"]["prompt_text"],
"projector_audit": projected["audit"],
}
return (
"You are Ghostlight's Maer-local choice generator. Call record_maer_choices exactly once.\n"
"Do not write Sella's response or private interpretation.\n\n"
+ json.dumps(payload, indent=2, ensure_ascii=False)
)
def sella_local_payload(fixture: dict[str, Any], annotation: dict[str, Any]) -> dict[str, Any]:
responder_id = annotation["npc_agent_ids"][0]
projected = build_projected_context(fixture, annotation, responder_id, "response_generation")
return projected["context"]["prompt_text"]
def build_sella_appraisal_prompt(fixture: dict[str, Any], annotation: dict[str, Any], selected_choice: dict[str, Any]) -> str:
scene_id = annotation["scene_id"]
responder_id = annotation["npc_agent_ids"][0]
actor_id = annotation["protagonist_agent_id"]
scene = find_scene(fixture, scene_id)
event_context = observable_action_payload(actor_id, selected_choice)
projected = build_projected_context(fixture, annotation, responder_id, "event_appraisal", event_context)
payload = {
"task": "Generate Sella's participant appraisal/consolidation for one observable Maer event.",
"output_contract": {
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
"root_keys": ["appraisal"],
"appraisal_fields": [
"responder_agent_id",
"private_interpretation",
"attributed_motive",
"misread_risk",
"immediate_state_deltas",
"relationship_deltas",
"belief_updates",
"response_constraints",
],
},
"hard_rules": [
"Use only Sella's local awareness plus the observable Maer action.",
"Do not stringify nested arrays or objects inside tool arguments.",
"Do not read Maer's private intent. Treat actor intent as unavailable.",
"Sella may correctly read, partially read, or misread Maer's action.",
"Do not resolve whether the corrupted packet contains a person.",
"This pass proposes reviewed state changes for Sella caused by the event. Do not write Sella's next action.",
"Keep deltas small and bounded between -0.20 and 0.20.",
"State delta paths must be chosen from the tool schema enum; do not invent abstract resource paths like sanctuary_capacity.",
"Relationship deltas must use relationship_id rel-sella-maer and a valid stance axis from the tool schema enum.",
],
"scene_observable_context": {
"scene_id": scene_id,
"location": scene["location"],
"public_stakes": scene["public_stakes"],
},
"observable_maer_action": event_context,
"projected_local_context": projected["context"]["prompt_text"],
"projector_audit": projected["audit"],
}
return (
"You are Ghostlight's Sella-local appraisal generator. Call record_sella_appraisal exactly once.\n"
"Do not use Maer's private intent. Do not write her next action.\n\n"
+ json.dumps(payload, indent=2, ensure_ascii=False)
)
def build_sella_prompt(
fixture: dict[str, Any],
annotation: dict[str, Any],
selected_choice: dict[str, Any],
appraisal: dict[str, Any] | None,
) -> str:
scene_id = annotation["scene_id"]
responder_id = annotation["npc_agent_ids"][0]
actor_id = annotation["protagonist_agent_id"]
scene = find_scene(fixture, scene_id)
event_context = observable_action_payload(actor_id, selected_choice)
projected = build_projected_context(fixture, annotation, responder_id, "next_action_generation", event_context)
payload = {
"task": "Generate Sella's next action from Sella-local awareness after applying reviewed event appraisal.",
"output_contract": {
"format": "Call the provided tool exactly once. Do not write plain assistant content.",
"root_keys": ["response"],
"valid_action_types": sorted(VALID_ACTION_TYPES),
"response_fields": [
"responder_agent_id",
"action_type",
"observable_response",
"spoken_text",
"sella_private_interpretation",
"sella_attributed_motive",
"misread_risk",
"state_basis",
"reviewed_consequences",
"training_hooks",
],
},
"hard_rules": [
"Use only Sella's local awareness, the observable Maer event, and reviewed Sella state changes from that event.",
"Do not stringify nested arrays or objects inside tool arguments.",
"Do not read Maer's private intent. Treat actor intent as unavailable.",
"Sella may correctly read, partially read, or misread Maer's action.",
"Do not resolve whether the corrupted packet contains a person.",
"Do not accept without cost, condition, authority defense, or resentment risk.",
f"Use action_type values only from this exact enum: {', '.join(sorted(VALID_ACTION_TYPES))}.",
"Use semantic training hooks, not branch ids.",
],
"scene_observable_context": {
"scene_id": scene_id,
"location": scene["location"],
"public_stakes": scene["public_stakes"],
},
"observable_maer_action": event_context,
"sella_immediate_appraisal_context": appraisal_context_for_prompt(appraisal),
"projected_local_context": projected["context"]["prompt_text"],
"projector_audit": projected["audit"],
}
return (
"You are Ghostlight's Sella-local next-action generator. Call record_sella_next_action exactly once.\n"
"Use reviewed event appraisal as the changed state she is acting from.\n\n"
+ json.dumps(payload, indent=2, ensure_ascii=False)
)
def appraisal_context_for_prompt(appraisal: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(appraisal, dict):
return None
state_change_summary = []
for delta in appraisal.get("immediate_state_deltas", []):
if isinstance(delta, dict) and delta.get("rationale"):
state_change_summary.append(delta["rationale"])
for delta in appraisal.get("relationship_deltas", []):
if isinstance(delta, dict) and delta.get("rationale"):
state_change_summary.append(delta["rationale"])
return {
"private_interpretation": appraisal.get("private_interpretation", ""),
"attributed_motive": appraisal.get("attributed_motive", ""),
"misread_risk": appraisal.get("misread_risk", ""),
"belief_updates": appraisal.get("belief_updates", []),
"response_constraints": appraisal.get("response_constraints", []),
"state_change_summary": state_change_summary,
}
def validate_choice_payload(parsed: Any) -> list[str]:
notes: list[str] = []
if not isinstance(parsed, dict):
return ["choice response was not an object"]
choices = parsed.get("choices")
if not isinstance(choices, list):
return ["choice response missing choices array"]
if len(choices) != 4:
notes.append(f"expected 4 choices, got {len(choices)}")
required = ["branch_id", "ink_path", "choice_text", "action_type", "actor_intent_private", "observable_action", "state_basis", "expected_consequence_surfaces", "training_hooks"]
non_speech = 0
for index, choice in enumerate(choices):
if not isinstance(choice, dict):
notes.append(f"choices[{index}] was not an object")
continue
missing = [key for key in required if key not in choice]
if missing:
notes.append(f"choices[{index}] missing keys: {', '.join(missing)}")
if choice.get("action_type") not in VALID_ACTION_TYPES:
notes.append(f"choices[{index}] invalid action_type")
for list_key in ["expected_consequence_surfaces", "training_hooks"]:
if not isinstance(choice.get(list_key), list):
notes.append(f"choices[{index}] {list_key} must be an array")
notes.extend(validate_maer_embodiment(choice, f"choices[{index}]"))
notes.extend(validate_cold_wake_object_semantics(choice, f"choices[{index}]"))
if choice.get("action_type") != "speak":
non_speech += 1
if non_speech < 2:
notes.append("choices had fewer than two non-speech actions")
return notes
def validate_maer_embodiment(choice: dict[str, Any], path: str) -> list[str]:
text = " ".join(str(choice.get(key, "")) for key in ["choice_text", "observable_action", "spoken_text", "state_basis"])
banned = [
"hand",
"hands",
"pocket",
"pockets",
"stand",
"stands",
"standing",
"walk",
"walks",
"walking",
"step",
"steps",
"stepping",
"lean",
"leans",
"leaning",
"doorway",
"doorframe",
]
hits = [word for word in banned if re.search(rf"\b{re.escape(word)}\b", text, re.IGNORECASE)]
if hits:
return [f"{path} violates Maer cetacean embodiment constraints: {', '.join(sorted(set(hits)))}"]
return []
def validate_cold_wake_object_semantics(choice: dict[str, Any], path: str) -> list[str]:
text = " ".join(str(choice.get(key, "")) for key in ["choice_text", "observable_action", "spoken_text", "state_basis"])
notes: list[str] = []
if re.search(r"\bpacket'?s ledger backing\b", text, re.IGNORECASE):
notes.append(f"{path} conflates packet object custody with route-ledger backing")
if re.search(r"\bpacket has weight\b|\bpacket's weight\b|\btangible object with potential value\b", text, re.IGNORECASE):
notes.append(f"{path} treats the ambiguous packet as weighted evidence beyond source constraints")
if choice.get("action_type") == "show_object" and re.search(r"\bledger backing\b", text, re.IGNORECASE):
notes.append(f"{path} uses show_object for ledger backing; use spend_resource for backing or show_object for the packet itself")
return notes
def validate_appraisal_payload(parsed: Any) -> list[str]:
if not isinstance(parsed, dict):
return ["appraisal was not an object"]
appraisal = parsed.get("appraisal")
if not isinstance(appraisal, dict):
return ["appraisal missing appraisal object"]
required = [
"responder_agent_id",
"private_interpretation",
"attributed_motive",
"misread_risk",
"immediate_state_deltas",
"relationship_deltas",
"response_constraints",
]
missing = [key for key in required if key not in appraisal]
notes = [f"appraisal missing keys: {', '.join(missing)}"] if missing else []
for list_key in ["immediate_state_deltas", "relationship_deltas", "response_constraints"]:
if list_key in appraisal and not isinstance(appraisal[list_key], list):
notes.append(f"appraisal.{list_key} must be an array")
for index, delta in enumerate(appraisal.get("immediate_state_deltas", [])):
if not isinstance(delta, dict):
notes.append(f"appraisal.immediate_state_deltas[{index}] must be an object")
continue
path = str(delta.get("path", ""))
if path not in VALID_SELLA_STATE_DELTA_PATHS:
notes.append(f"appraisal.immediate_state_deltas[{index}].path is not an allowed Sella state path: {path}")
for index, delta in enumerate(appraisal.get("relationship_deltas", [])):
if not isinstance(delta, dict):
notes.append(f"appraisal.relationship_deltas[{index}] must be an object")
continue
relationship_id = str(delta.get("relationship_id", ""))
path = str(delta.get("path", ""))
if relationship_id not in VALID_RELATIONSHIP_IDS:
notes.append(f"appraisal.relationship_deltas[{index}].relationship_id must be a relationship id, got {relationship_id}")
if path not in VALID_RELATIONSHIP_DELTA_PATHS:
notes.append(f"appraisal.relationship_deltas[{index}].path is not a relationship stance axis: {path}")
return notes
def validate_response_payload(parsed: Any) -> list[str]:
if not isinstance(parsed, dict):
return ["response was not an object"]
response = parsed.get("response")
if not isinstance(response, dict):
return ["response missing response object"]
required = ["responder_agent_id", "action_type", "observable_response", "sella_private_interpretation", "misread_risk", "state_basis", "reviewed_consequences", "training_hooks"]
missing = [key for key in required if key not in response]
notes = [f"response missing keys: {', '.join(missing)}"] if missing else []
if response.get("action_type") not in VALID_ACTION_TYPES:
notes.append("response invalid action_type")
if "training_hooks" in response and not isinstance(response["training_hooks"], list):
notes.append("response.training_hooks must be an array")
notes.extend(validate_response_prompt_leakage(response))
return notes
def validate_response_prompt_leakage(response: dict[str, Any]) -> list[str]:
text = " ".join(
str(response.get(key, ""))
for key in [
"observable_response",
"spoken_text",
"reviewed_consequences",
"sella_private_interpretation",
"state_basis",
]
)
leaked_patterns = [
r"\bDo not resolve\b",
r"\bDo not accept\b",
r"\bDo not let\b",
r"\bmust not resolve\b",
r"\bmust not make\b",
r"\bmust frame the scene\b",
r"\bmust speak warmly\b",
r"\bmust treat care\b",
r"\bSpeak warmly\b",
r"\bDemand ledger backing\b",
r"\btreat it as unresolved uncertainty\b",
r"\bKeep sanctuary intake materially possible\b",
r"\bSpeak the personhood\b",
]
hits = [pattern for pattern in leaked_patterns if re.search(pattern, text, re.IGNORECASE)]
if hits:
return [f"response appears to leak prompt constraints into prose: {', '.join(hits)}"]
return []
def choose_selected_choice(parsed: Any, preferred_action: str) -> dict[str, Any]:
choices = parsed.get("choices", []) if isinstance(parsed, dict) else []
for choice in choices:
if isinstance(choice, dict) and choice.get("action_type") == preferred_action:
return choice
for choice in choices:
if isinstance(choice, dict):
return choice
raise SystemExit("No choices available to select")
def write_sequential_artifacts(args: argparse.Namespace, capture: dict[str, Any], prompts: dict[str, str], responses: dict[str, str]) -> Path:
args.out_dir.mkdir(parents=True, exist_ok=True)
base = args.capture_id.replace(".", "-")
capture_path = args.out_dir / f"{base}.capture.json"
write_json(capture_path, capture)
for name, text in prompts.items():
write_text(args.out_dir / f"{base}.{name}.prompt.md", text + "\n")
for name, text in responses.items():
write_text(args.out_dir / f"{base}.{name}.response.md", text + "\n")
return capture_path
def skipped_pass(local_agent_id: str, reason: str) -> dict[str, Any]:
return {
"local_agent_id": local_agent_id,
"prompt_text": f"Skipped: {reason}",
"response_text": f"Skipped: {reason}",
"parsed_response": None,
"validation_notes": [reason],
}
def main() -> int:
parser = argparse.ArgumentParser(description="Run a sequential local-awareness Qwen Ink prototype.")
parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE)
parser.add_argument("--annotation", type=Path, default=DEFAULT_ANNOTATION)
parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR)
parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT)
parser.add_argument("--model", default="qwen3.5:9b")
parser.add_argument("--temperature", type=float, default=0.55)
parser.add_argument("--top-p", type=float, default=0.9)
parser.add_argument("--num-ctx", type=int, default=8192)
parser.add_argument("--request-timeout", type=int, default=600)
parser.add_argument("--think", action=argparse.BooleanOptionalAction, default=False)
parser.add_argument("--capture-id", default="cold-wake-sanctuary-intake.qwen-sequential.v1")
parser.add_argument("--preferred-action", default="show_object")
args = parser.parse_args()
fixture = load_json(args.fixture)
annotation = load_json(args.annotation)
maer_prompt = build_maer_prompt(fixture, annotation)
maer_raw, maer_response_text, maer_parsed, maer_notes = request_qwen_tool(
args.endpoint,
args.model,
maer_prompt,
maer_choices_tool(),
"record_maer_choices",
args.temperature,
args.top_p,
args.num_ctx,
args.think,
args.request_timeout,
)
maer_notes.extend(validate_choice_payload(maer_parsed))
try:
selected_choice = choose_selected_choice(maer_parsed, args.preferred_action)
except SystemExit as exc:
selected_choice = {
"branch_id": "no-selection",
"ink_path": "no_selection",
"choice_text": "No selectable Maer choice was produced.",
"action_type": "no_valid_action",
"observable_action": "No observable action was selected because Maer choice generation failed.",
"spoken_text": "",
"expected_consequence_surfaces": [],
"training_hooks": [],
}
maer_notes.append(str(exc))
all_notes = fatal_notes(maer_notes)
capture = {
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
"capture_id": args.capture_id,
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
"model": maer_raw.get("model", args.model),
"endpoint": args.endpoint,
"request_options": {
"think": args.think,
"api": "ollama_chat_tools",
"num_ctx": args.num_ctx,
"request_timeout": args.request_timeout,
"temperature": args.temperature,
"top_p": args.top_p,
},
"passes": {
"maer_choice_generation": {
"local_agent_id": "maer_tidecall",
"prompt_text": maer_prompt,
"response_text": maer_response_text,
"parsed_response": maer_parsed,
"validation_notes": maer_notes,
},
"selected_observable_action": selected_choice,
"sella_response_generation": skipped_pass("sella_ren", "skipped because no selectable Maer choice was produced"),
},
"review": {
"status": "useful_needs_revision",
"strengths": [
"Captured a failed projector-routed Qwen call instead of exiting without a receipt.",
],
"failure_notes": all_notes,
"repair_notes": repair_notes(maer_notes),
"accepted_into_ink_ref": None,
"pipeline_lessons": [
"Sequential generation failures should still produce reviewed receipts.",
"Maer choice generation needs a retry or stricter tool-call fallback before later passes run.",
],
},
}
capture_path = write_sequential_artifacts(
args,
capture,
{"maer": maer_prompt},
{"maer": maer_response_text},
)
print(f"wrote {capture_path.relative_to(ROOT)}")
print(json.dumps({"selected_observable_action": selected_choice, "failure_notes": all_notes}, indent=2, ensure_ascii=False))
return 0
sella_appraisal_prompt = build_sella_appraisal_prompt(fixture, annotation, selected_choice)
sella_appraisal_raw, sella_appraisal_response_text, sella_appraisal_parsed, sella_appraisal_notes = request_qwen_tool(
args.endpoint,
args.model,
sella_appraisal_prompt,
sella_appraisal_tool(),
"record_sella_appraisal",
args.temperature,
args.top_p,
args.num_ctx,
args.think,
args.request_timeout,
)
sella_appraisal_parsed, appraisal_repair_notes = normalize_appraisal_payload(sella_appraisal_parsed)
sella_appraisal_notes.extend(appraisal_repair_notes)
sella_appraisal_notes.extend(validate_appraisal_payload(sella_appraisal_parsed))
appraisal = sella_appraisal_parsed.get("appraisal") if isinstance(sella_appraisal_parsed, dict) else None
appraisal_fatal_notes = fatal_notes(sella_appraisal_notes)
if appraisal_fatal_notes:
capture = {
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
"capture_id": args.capture_id,
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
"model": maer_raw.get("model", args.model),
"endpoint": args.endpoint,
"request_options": {
"think": args.think,
"api": "ollama_chat_tools",
"num_ctx": args.num_ctx,
"request_timeout": args.request_timeout,
"temperature": args.temperature,
"top_p": args.top_p,
},
"passes": {
"maer_choice_generation": {
"local_agent_id": "maer_tidecall",
"prompt_text": maer_prompt,
"response_text": maer_response_text,
"parsed_response": maer_parsed,
"validation_notes": maer_notes,
},
"selected_observable_action": selected_choice,
"sella_immediate_appraisal": {
"local_agent_id": "sella_ren",
"prompt_text": sella_appraisal_prompt,
"response_text": sella_appraisal_response_text,
"parsed_response": sella_appraisal_parsed,
"validation_notes": sella_appraisal_notes,
},
"sella_response_generation": skipped_pass("sella_ren", "skipped because Sella appraisal failed validation or request"),
},
"review": {
"status": "useful_needs_revision",
"strengths": [
"Generated Maer-local choices before the later-pass failure.",
"Captured the failed Sella appraisal pass instead of losing the run.",
],
"failure_notes": fatal_notes(maer_notes + sella_appraisal_notes),
"repair_notes": repair_notes(maer_notes + sella_appraisal_notes),
"accepted_into_ink_ref": None,
"pipeline_lessons": [
"Later-pass transport or tool failures must still write reviewed receipts.",
"Critical embodiment constraints need post-generation validation; projected prose alone is not enough.",
],
},
}
capture_path = write_sequential_artifacts(
args,
capture,
{"maer": maer_prompt, "sella-appraisal": sella_appraisal_prompt},
{"maer": maer_response_text, "sella-appraisal": sella_appraisal_response_text},
)
print(f"wrote {capture_path.relative_to(ROOT)}")
print(json.dumps({"selected_observable_action": selected_choice, "failure_notes": capture["review"]["failure_notes"]}, indent=2, ensure_ascii=False))
return 0
sella_prompt = build_sella_prompt(fixture, annotation, selected_choice, appraisal)
sella_raw, sella_response_text, sella_parsed, sella_notes = request_qwen_tool(
args.endpoint,
args.model,
sella_prompt,
sella_next_action_tool(),
"record_sella_next_action",
args.temperature,
args.top_p,
args.num_ctx,
args.think,
args.request_timeout,
)
sella_notes.extend(validate_response_payload(sella_parsed))
repair_note_list = repair_notes(maer_notes + sella_appraisal_notes + sella_notes)
all_notes = fatal_notes(maer_notes + sella_appraisal_notes + sella_notes)
capture = {
"schema_version": "ghostlight.qwen_ink_sequential_capture.v0",
"capture_id": args.capture_id,
"source_fixture_ref": str(args.fixture.relative_to(ROOT)).replace("\\", "/"),
"source_annotation_ref": str(args.annotation.relative_to(ROOT)).replace("\\", "/"),
"model": maer_raw.get("model", args.model),
"endpoint": args.endpoint,
"request_options": {
"think": args.think,
"api": "ollama_chat_tools",
"num_ctx": args.num_ctx,
"request_timeout": args.request_timeout,
"temperature": args.temperature,
"top_p": args.top_p,
},
"passes": {
"maer_choice_generation": {
"local_agent_id": "maer_tidecall",
"prompt_text": maer_prompt,
"response_text": maer_response_text,
"parsed_response": maer_parsed,
"validation_notes": maer_notes,
},
"selected_observable_action": selected_choice,
"sella_immediate_appraisal": {
"local_agent_id": "sella_ren",
"prompt_text": sella_appraisal_prompt,
"response_text": sella_appraisal_response_text,
"parsed_response": sella_appraisal_parsed,
"validation_notes": sella_appraisal_notes,
},
"sella_response_generation": {
"local_agent_id": "sella_ren",
"prompt_text": sella_prompt,
"response_text": sella_response_text,
"parsed_response": sella_parsed,
"validation_notes": sella_notes,
},
},
"review": {
"status": "accepted_as_draft" if not all_notes and not repair_note_list else "useful_needs_revision",
"strengths": [
"Separated Maer choice generation from Sella next-action generation.",
"Inserted participant appraisal/consolidation before next-actor action generation.",
"Sella prompt received observable Maer event and reviewed Sella state changes, not Maer's private intent.",
],
"failure_notes": all_notes,
"repair_notes": repair_note_list,
"accepted_into_ink_ref": None,
"pipeline_lessons": [
"Sequential generation needs event appraisal/consolidation before next actor selection.",
"Action type selection should be tool-shaped or schema-constrained, not left to prose compliance.",
"Character-local prompts should consume projected operating context, not raw canonical state variables.",
"Next-action prompts should receive appraisal context as prose constraints, not raw numeric state deltas.",
"Critical embodiment constraints need post-generation validation; projected prose alone is not enough.",
"Next pass should materialize the sequential capture into Ink and mutation training data.",
],
},
}
capture_path = write_sequential_artifacts(
args,
capture,
{
"maer": maer_prompt,
"sella-appraisal": sella_appraisal_prompt,
"sella": sella_prompt,
},
{
"maer": maer_response_text,
"sella-appraisal": sella_appraisal_response_text,
"sella": sella_response_text,
},
)
print(f"wrote {capture_path.relative_to(ROOT)}")
print(json.dumps({"selected_observable_action": selected_choice, "sella_appraisal": sella_appraisal_parsed, "sella_response": sella_parsed}, indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())