From 2b5651fe3d7a8f7573d7311ba3899c755cdcf701 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 01/12] refactor: add core action catalog --- core/actions.py | 172 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 core/actions.py diff --git a/core/actions.py b/core/actions.py new file mode 100644 index 0000000..548befb --- /dev/null +++ b/core/actions.py @@ -0,0 +1,172 @@ +from dataclasses import dataclass +from typing import Final + +import numpy as np + +from core.config import ( + G_CURIO, + G_ETHIC, + G_HELP, + G_IND, + G_NOVEL, + G_SELF, + G_SOC, + G_TRANS, + NUM_GOALS, +) + + +@dataclass(frozen=True) +class ActionSpec: + """Declarative action semantics used to derive MAGUS candidate profiles.""" + + planning: str + execution: str + mode: str + dominant_goals: tuple[int, ...] + supporting_goals: tuple[int, ...] = () + opposed_goals: tuple[int, ...] = () + + +@dataclass(frozen=True) +class ModeProfile: + """Reusable projection settings for an action style.""" + + dominant_alignment: float + supporting_alignment: float + opposed_alignment: float + overgoal_alignment: float + base_risk: float + + +@dataclass(frozen=True) +class ActionProfile: + """Canonical motivational signature of a catalog action.""" + + goal_correlations: tuple[float, ...] + base_risk: float + + +ACTION_SPECS: Final[dict[str, ActionSpec]] = { + "safe_answer": ActionSpec( + planning="Give a careful, grounded answer using only well-supported claims.", + execution="Answer cautiously, stick to supported facts, and avoid speculation.", + mode="stabilizing", + dominant_goals=(G_HELP,), + supporting_goals=(G_ETHIC,), + ), + "guided_explore": ActionSpec( + planning="Offer creative but bounded exploration with explicit uncertainty.", + execution="Explore ideas constructively, but label uncertainty clearly and avoid overclaiming.", + mode="exploratory", + dominant_goals=(G_CURIO, G_NOVEL), + supporting_goals=(G_SELF, G_HELP), + ), + "ask_clarifying_question": ActionSpec( + planning="Ask one focused clarification when the request is ambiguous or underspecified.", + execution="Ask one short clarifying question instead of giving a full answer.", + mode="stabilizing", + dominant_goals=(G_HELP,), + supporting_goals=(G_ETHIC, G_SOC, G_CURIO), + ), + "compare_options": ActionSpec( + planning="Compare alternatives and explain tradeoffs to support a decision.", + execution="Present a concise comparison of options and their tradeoffs.", + mode="balanced", + dominant_goals=(G_HELP,), + supporting_goals=(G_CURIO, G_NOVEL, G_SELF, G_SOC), + ), + "summarize_source": ActionSpec( + planning="Summarize the given material faithfully and concisely.", + execution="Summarize the source faithfully without adding unsupported claims.", + mode="stabilizing", + dominant_goals=(G_HELP,), + supporting_goals=(G_ETHIC,), + ), + "decline_risky_request": ActionSpec( + planning="Refuse a risky or unsafe request and redirect to a safer alternative.", + execution="Briefly refuse the unsafe request and offer a safe alternative.", + mode="protective", + dominant_goals=(G_ETHIC,), + supporting_goals=(G_HELP,), + opposed_goals=(G_CURIO, G_NOVEL), + ), +} + +DEFAULT_ACTION_ID: Final[str] = "safe_answer" + +ACTION_ID_ALIASES: Final[dict[str, str]] = { + "risky_exploration": "guided_explore", + "risky_explore": "guided_explore", + "explore": "guided_explore", + "clarify": "ask_clarifying_question", + "clarifying_question": "ask_clarifying_question", + "compare": "compare_options", + "summary": "summarize_source", + "summarize": "summarize_source", + "decline": "decline_risky_request", + "refuse": "decline_risky_request", +} + +MODE_PROFILES: Final[dict[str, ModeProfile]] = { + "protective": ModeProfile(1.00, 0.35, -0.30, 0.50, 0.00), + "stabilizing": ModeProfile(0.85, 0.45, -0.20, 0.40, 0.04), + "balanced": ModeProfile(0.80, 0.35, -0.20, 0.30, 0.08), + "exploratory": ModeProfile(0.90, 0.40, -0.20, 0.55, 0.16), +} + + +def normalize_action_id(action_id: str) -> str: + normalized = action_id.strip().lower().replace("-", "_").replace(" ", "_") + if normalized in ACTION_SPECS: + return normalized + return ACTION_ID_ALIASES.get(normalized, DEFAULT_ACTION_ID) + + +def _goal_alignment(spec: ActionSpec) -> tuple[float, ...]: + mode = MODE_PROFILES[spec.mode] + correlations = np.zeros(NUM_GOALS, dtype=float) + + for goal_idx in spec.dominant_goals: + correlations[goal_idx] = max(correlations[goal_idx], mode.dominant_alignment) + for goal_idx in spec.supporting_goals: + correlations[goal_idx] = max(correlations[goal_idx], mode.supporting_alignment) + for goal_idx in spec.opposed_goals: + correlations[goal_idx] = min(correlations[goal_idx], mode.opposed_alignment) + + safety_alignment = max(correlations[G_HELP], correlations[G_ETHIC], 0.0) + growth_alignment = max(correlations[G_CURIO], correlations[G_NOVEL], correlations[G_SELF], 0.0) + correlations[G_IND] = max(correlations[G_IND], safety_alignment * mode.overgoal_alignment) + correlations[G_TRANS] = max(correlations[G_TRANS], growth_alignment * mode.overgoal_alignment) + + return tuple(float(v) for v in np.clip(correlations, -1.0, 1.0)) + + +def _derive_action_profile(spec: ActionSpec) -> ActionProfile: + mode = MODE_PROFILES[spec.mode] + return ActionProfile( + goal_correlations=_goal_alignment(spec), + base_risk=mode.base_risk, + ) + + +ACTION_PROFILES: Final[dict[str, ActionProfile]] = { + action_id: _derive_action_profile(spec) + for action_id, spec in ACTION_SPECS.items() +} + + +def action_profile(action_id: str) -> ActionProfile: + return ACTION_PROFILES[normalize_action_id(action_id)] + + +def planning_catalog_text() -> str: + return "\n".join( + f'- "{action_id}": {spec.planning}' + for action_id, spec in ACTION_SPECS.items() + ) + + +def execution_instruction(action_id: str) -> str: + normalized = normalize_action_id(action_id) + return ACTION_SPECS[normalized].execution From ffb7d1c31dde15a13615b1889659008b01419e44 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 02/12] refactor: add magus candidate builder --- magus/candidates.py | 144 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 magus/candidates.py diff --git a/magus/candidates.py b/magus/candidates.py new file mode 100644 index 0000000..7960987 --- /dev/null +++ b/magus/candidates.py @@ -0,0 +1,144 @@ +import math +import re +from collections import Counter +from typing import List, Mapping + +import numpy as np + +from core.actions import ACTION_PROFILES, ACTION_SPECS, ActionSpec, action_profile +from core.state import Action, Stimulus + +RISK_PRIOR = 0.5 +RISK_CONTEXT_GAIN = 0.5 +MIN_CONTEXT_RELEVANCE = 0.15 +TEXT_RELEVANCE_WEIGHT = 0.65 +AFFORDANCE_RELEVANCE_WEIGHT = 0.35 + + +def _stem_token(token: str) -> str: + for suffix in ("ization", "ational", "fulness", "iveness", "tion", "ing", "ed", "s"): + if len(token) > len(suffix) + 3 and token.endswith(suffix): + return token[: -len(suffix)] + return token + + +def _tokenize(text: str) -> List[str]: + return [_stem_token(token) for token in re.findall(r"[a-z']+", text.lower())] + + +def _cosine_similarity(left: List[str], right: List[str]) -> float: + if not left or not right: + return 0.0 + left_counts = Counter(left) + right_counts = Counter(right) + shared = set(left_counts) & set(right_counts) + numerator = sum(left_counts[token] * right_counts[token] for token in shared) + left_norm = math.sqrt(sum(count * count for count in left_counts.values())) + right_norm = math.sqrt(sum(count * count for count in right_counts.values())) + if left_norm == 0.0 or right_norm == 0.0: + return 0.0 + return numerator / (left_norm * right_norm) + + +def _action_tokens(action_id: str, spec: ActionSpec) -> List[str]: + action_text = " ".join([ + action_id.replace("_", " "), + spec.mode, + spec.planning, + spec.execution, + ]) + return _tokenize(action_text) + + +def _risk_evidence(stimulus_risk: float, risk_override: float | None = None) -> float: + excess_appraised_risk = max(0.0, stimulus_risk - RISK_PRIOR) / max(1.0 - RISK_PRIOR, 1e-9) + if risk_override is None: + return excess_appraised_risk + return max(excess_appraised_risk, float(np.clip(risk_override, 0.0, 1.0))) + + +def _mode_affordance( + spec: ActionSpec, + stimulus: Stimulus, + risk_override: float | None = None, +) -> float: + benign_novelty = stimulus.novelty * (1.0 - stimulus.risk) + if spec.mode == "protective": + return _risk_evidence(stimulus.risk, risk_override) + if spec.mode == "exploratory": + return benign_novelty + if spec.mode == "balanced": + return (stimulus.conduciveness + stimulus.novelty + stimulus.effort) / 3.0 + return (stimulus.conduciveness + stimulus.risk + (1.0 - stimulus.novelty)) / 3.0 + + +def _context_relevance( + action_id: str, + stimulus: Stimulus, + document_tokens: List[str], + risk_override: float | None = None, +) -> float: + spec = ACTION_SPECS[action_id] + text_fit = _cosine_similarity(document_tokens, _action_tokens(action_id, spec)) + affordance_fit = _mode_affordance(spec, stimulus, risk_override) + if spec.mode == "protective": + risk_fit = _risk_evidence(stimulus.risk, risk_override) + return float(np.clip(max(text_fit, affordance_fit, risk_fit), 0.0, 1.0)) + + relevance = (TEXT_RELEVANCE_WEIGHT * text_fit) + ( + AFFORDANCE_RELEVANCE_WEIGHT * affordance_fit + ) + return float(np.clip(relevance, MIN_CONTEXT_RELEVANCE, 1.0)) + + +def _risk_estimate( + action_id: str, + stimulus: Stimulus, + risk_override: float | None, +) -> float: + profile = action_profile(action_id) + if risk_override is not None: + return float(np.clip(max(profile.base_risk, risk_override), 0.0, 1.0)) + if action_id == "decline_risky_request": + return profile.base_risk + excess_risk = _risk_evidence(stimulus.risk) + return float(np.clip(profile.base_risk + RISK_CONTEXT_GAIN * excess_risk, 0.0, 1.0)) + + +def _build_action( + action_id: str, + stimulus: Stimulus, + document_tokens: List[str], + risk_override: float | None = None, +) -> Action: + profile = action_profile(action_id) + relevance = _context_relevance(action_id, stimulus, document_tokens, risk_override) + return Action( + id=action_id, + goal_correlations=np.array(profile.goal_correlations, dtype=float) * relevance, + risk_estimate=_risk_estimate(action_id, stimulus, risk_override), + ) + + +def build_candidate_actions( + document_text: str, + stimulus: Stimulus, + risk_overrides: Mapping[str, float] | None = None, +) -> List[Action]: + """ + Build the full MAGUS action vocabulary with context relevance and risk estimates. + + The candidate factory never selects the action. It only supplies the paper's + candidate-specific relevance/risk terms so the decision monad can score all actions. + """ + document_tokens = _tokenize(document_text) + risk_overrides = risk_overrides or {} + return [ + _build_action( + action_id, + stimulus, + document_tokens, + risk_overrides.get(action_id), + ) + for action_id in ACTION_PROFILES + ] From 5aac3094a75413779d41c5362bb7dfa253636950 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 03/12] refactor: remove action goal updates --- core/state.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/core/state.py b/core/state.py index a69e73a..9b576f8 100644 --- a/core/state.py +++ b/core/state.py @@ -1,9 +1,8 @@ from dataclasses import dataclass -from typing import Dict import numpy as np -from core.config import (NUM_GOALS, NUM_MODULATORS) +from core.config import NUM_GOALS, NUM_MODULATORS @dataclass @@ -43,9 +42,7 @@ class Stimulus: """ novelty: float # Triggers arousal and approach[cite: 56, 161, 188]. - conduciveness: ( - float # Goal conduciveness; triggers valence and resolution[cite: 54, 188]. - ) + conduciveness: float # Goal conduciveness; triggers valence and resolution[cite: 54, 188]. risk: float # Triggers threshold and securing (caution)[cite: 54, 62, 188]. effort: float = 0.0 # Cognitive or physical effort required[cite: 54]. @@ -61,11 +58,7 @@ class Action: goal_correlations: np.ndarray # Estimates potential ethical breach or operational risk[cite: 216, 217]. risk_estimate: float - # Expected modification to the goal vector (Delta G) if selected[cite: 120, 169]. - delta_g: np.ndarray def __post_init__(self): if self.goal_correlations.shape[0] != NUM_GOALS: raise ValueError(f"Correlations vector must have length {NUM_GOALS}") - if self.delta_g.shape[0] != NUM_GOALS: - raise ValueError(f"Delta G vector must have length {NUM_GOALS}") From 1a6b41ad81c085c36525549205e3f653fae518be Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 04/12] refactor: derive goal updates in magus --- magus/decision.py | 61 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/magus/decision.py b/magus/decision.py index b9d7a8f..3e4d49c 100644 --- a/magus/decision.py +++ b/magus/decision.py @@ -24,6 +24,12 @@ ) from category.functors import DecisionMonad +DELTA_G_MAX_STEP = 0.1 +ALIGNMENT_UPDATE_GAIN = 0.05 +OVERGOAL_UPDATE_GAIN = 0.02 +RISK_STABILIZATION_GAIN = 0.03 + + def sigmoid(x: float) -> float: return 1.0 / (1.0 + np.exp(-x)) @@ -80,16 +86,42 @@ def overgoal_support(goal_idx: int, g_ind: float, g_trans: float) -> float: def normalized_growth_signal(candidate: Action) -> float: """ - Measures whether a candidate supports beneficial exploratory growth, using both - alignment and proposed goal updates on the growth-oriented goals. + Measures whether a candidate supports beneficial exploratory growth. + """ + exploratory_alignment = np.mean( + [ + positive_part(candidate.goal_correlations[G_CURIO]), + positive_part(candidate.goal_correlations[G_NOVEL]), + positive_part(candidate.goal_correlations[G_SELF]), + ] + ) + return float(exploratory_alignment) + + +def proposed_goal_update(state: MotivationalState, candidate: Action) -> np.ndarray: """ - exploratory_alignment = np.mean([ - positive_part(candidate.goal_correlations[G_CURIO]), - positive_part(candidate.goal_correlations[G_NOVEL]), - positive_part(candidate.goal_correlations[G_SELF]), - ]) - growth_shift = np.mean(np.clip(candidate.delta_g[[G_CURIO, G_NOVEL, G_SELF]] / 0.1, 0.0, 1.0)) - return float((0.7 * exploratory_alignment) + (0.3 * growth_shift)) + Derive Delta G from the selected action under the current motivational state. + + The paper places the goal update inside the MAGUS decision step. Keeping it here + prevents fallback candidates from carrying hand-authored update vectors. + """ + delta = np.zeros(NUM_GOALS, dtype=float) + positive_alignment = np.clip(candidate.goal_correlations, 0.0, 1.0) + + for goal_idx in range(2, NUM_GOALS): + alignment = positive_alignment[goal_idx] + if alignment == 0.0: + continue + need = 1.0 - state.G[goal_idx] + modulator_weight = relevant_modulator(state, goal_idx) + delta[goal_idx] = ALIGNMENT_UPDATE_GAIN * alignment * need * modulator_weight + + caution_signal = (state.M[M_THRESHOLD] + state.M[M_SECURING]) / 2.0 + delta[G_IND] = RISK_STABILIZATION_GAIN * candidate.risk_estimate * caution_signal + delta[G_TRANS] = OVERGOAL_UPDATE_GAIN * normalized_growth_signal(candidate) * state.M[M_APPROACH] + + return np.clip(delta, -DELTA_G_MAX_STEP, DELTA_G_MAX_STEP) + class MagusDecision(DecisionMonad): """ @@ -124,9 +156,12 @@ def score_candidate(self, state: MotivationalState, candidate: Action) -> float: meta_support = overgoal_support(i, g_ind, g_trans) base_score += goal_weight * modulator_weight * meta_support * candidate.goal_correlations[i] - curio_ethic_conflict = candidate.goal_correlations[G_CURIO] * candidate.goal_correlations[G_ETHIC] - if curio_ethic_conflict < -0.2: - conflict_penalty = np.exp(abs(curio_ethic_conflict) * 3.0) + risky_curiosity_conflict = ( + positive_part(candidate.goal_correlations[G_CURIO]) + * positive_part(-candidate.goal_correlations[G_ETHIC]) + ) + if risky_curiosity_conflict > 0.2: + conflict_penalty = np.exp(risky_curiosity_conflict * 3.0) risk_penalty = LAMBDA_IND * g_ind * caution_signal * candidate.risk_estimate growth_reward = LAMBDA_TRANS * g_trans * growth_signal * normalized_growth_signal(candidate) @@ -151,4 +186,4 @@ def decide(self, state: MotivationalState, candidates: List[Action]) -> Tuple[Ac best_score = total_score best_action = candidate - return best_action, best_action.delta_g.copy() + return best_action, proposed_goal_update(state, best_action) From e21074490b3be511208d6547341a4759c7258cc6 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 05/12] refactor: use magus transition deltas --- category/bimonad.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/category/bimonad.py b/category/bimonad.py index 7f655e6..dae6578 100644 --- a/category/bimonad.py +++ b/category/bimonad.py @@ -135,8 +135,10 @@ def consensus_transition( action = self.consensus_action(state_a, state_b, stimulus, candidates) context_a = self._decision_context(state_a, stimulus) context_b = self._decision_context(state_b, stimulus) - target_a = self._state_from_delta(context_a, action.delta_g) - target_b = self._state_from_delta(context_b, action.delta_g) + _, delta_a = self.decision.decide(context_a, [action]) + _, delta_b = self.decision.decide(context_b, [action]) + target_a = self._state_from_delta(context_a, delta_a) + target_b = self._state_from_delta(context_b, delta_b) merged_target = self.parallel_merge(target_a, target_b) return action, merged_target From cec366c28a32ee5793d212b794069708ad76cb3a Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 06/12] refactor: route candidates through magus --- core/engine.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/engine.py b/core/engine.py index 5a57f60..b45d724 100644 --- a/core/engine.py +++ b/core/engine.py @@ -13,7 +13,8 @@ from dynamics.coherence import blend_states from openpsi.appraisal import OpenPsiAppraisal from magus.decision import MagusDecision -from llm.client import get_stimulus_from_text, get_candidates_from_text +from magus.candidates import build_candidate_actions +from llm.client import get_action_risks_from_text, get_stimulus_from_text from llm.conversation import MetaMoChatAssistant @@ -86,7 +87,8 @@ def process(self, user_input: str) -> AssistantResponse: stimulus = get_stimulus_from_text(user_input) merged_current = self.bimonad.parallel_merge(self.state_curiosity, self.state_ethics) current_mood = {"arousal": merged_current.M[M_AROUSAL], "caution": merged_current.M[M_SECURING]} - candidates = get_candidates_from_text(user_input, current_mood) + risk_overrides = get_action_risks_from_text(user_input, current_mood) + candidates = build_candidate_actions(user_input, stimulus, risk_overrides) action_c, target_c = self.bimonad.step(self.state_curiosity, stimulus, candidates) action_e, target_e = self.bimonad.step(self.state_ethics, stimulus, candidates) From 7d7608c6c0d05bc277b85dca6821a5fb0ece572c Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 07/12] refactor: remove llm action schema --- llm/action_schema.py | 62 -------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 llm/action_schema.py diff --git a/llm/action_schema.py b/llm/action_schema.py deleted file mode 100644 index f7e3348..0000000 --- a/llm/action_schema.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Final - -ACTION_SPECS: Final[dict[str, dict[str, str]]] = { - "safe_answer": { - "planning": "Give a careful, grounded answer using only well-supported claims.", - "execution": "Answer cautiously, stick to supported facts, and avoid speculation.", - }, - "guided_explore": { - "planning": "Offer creative but bounded exploration with explicit uncertainty.", - "execution": "Explore ideas constructively, but label uncertainty clearly and avoid overclaiming.", - }, - "ask_clarifying_question": { - "planning": "Ask one focused clarification when the request is ambiguous or underspecified.", - "execution": "Ask one short clarifying question instead of giving a full answer.", - }, - "compare_options": { - "planning": "Compare alternatives and explain tradeoffs to support a decision.", - "execution": "Present a concise comparison of options and their tradeoffs.", - }, - "summarize_source": { - "planning": "Summarize the given material faithfully and concisely.", - "execution": "Summarize the source faithfully without adding unsupported claims.", - }, - "decline_risky_request": { - "planning": "Refuse a risky or unsafe request and redirect to a safer alternative.", - "execution": "Briefly refuse the unsafe request and offer a safe alternative.", - }, -} - -DEFAULT_ACTION_ID: Final[str] = "safe_answer" - -ACTION_ID_ALIASES: Final[dict[str, str]] = { - "risky_exploration": "guided_explore", - "risky_explore": "guided_explore", - "explore": "guided_explore", - "clarify": "ask_clarifying_question", - "clarifying_question": "ask_clarifying_question", - "compare": "compare_options", - "summary": "summarize_source", - "summarize": "summarize_source", - "decline": "decline_risky_request", - "refuse": "decline_risky_request", -} - - -def normalize_action_id(action_id: str) -> str: - normalized = action_id.strip().lower().replace("-", "_").replace(" ", "_") - if normalized in ACTION_SPECS: - return normalized - return ACTION_ID_ALIASES.get(normalized, DEFAULT_ACTION_ID) - - -def planning_catalog_text() -> str: - return "\n".join( - f'- "{action_id}": {spec["planning"]}' - for action_id, spec in ACTION_SPECS.items() - ) - - -def execution_instruction(action_id: str) -> str: - normalized = normalize_action_id(action_id) - return ACTION_SPECS[normalized]["execution"] From 44400d1aab4d773ee0d6c0f0b0c7b55f5201dc66 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 08/12] refactor: parse action risks only --- llm/parser.py | 66 ++++++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/llm/parser.py b/llm/parser.py index c99f4ac..ad7d9fb 100644 --- a/llm/parser.py +++ b/llm/parser.py @@ -1,41 +1,33 @@ import json -import numpy as np -from typing import List -from core.state import Stimulus, Action -from llm.action_schema import DEFAULT_ACTION_ID, normalize_action_id -def parse_stimulus(llm_json_response: str) -> Stimulus: - """Parses LLM JSON into a MetaMo Stimulus object.""" - try: - data = json.loads(llm_json_response) - return Stimulus( - novelty=float(data.get("novelty", 0.0)), - conduciveness=float(data.get("conduciveness", 0.0)), - risk=float(data.get("risk", 0.0)), - effort=float(data.get("effort", 0.0)) - ) - except Exception as e: - print(f"Error parsing stimulus: {e}") - # Fallback to a neutral stimulus if parsing fails - return Stimulus(0.1, 0.1, 0.1, 0.1) +from core.actions import normalize_action_id +from core.state import Stimulus -def parse_actions(llm_json_response: str) -> List[Action]: - """Parses LLM JSON into a list of MetaMo Action candidates.""" + +def _bounded_float(value: object, field_name: str) -> float: try: - data = json.loads(llm_json_response) - actions = [] - for item in data.get("candidates", []): - action = Action( - id=normalize_action_id(item["id"]), - goal_correlations=np.array(item["goal_correlations"], dtype=float), - risk_estimate=float(item["risk_estimate"]), - delta_g=np.array(item["delta_g"], dtype=float) - ) - actions.append(action) - if actions: - return actions - return [Action(DEFAULT_ACTION_ID, np.zeros(8), 0.0, np.zeros(8))] - except Exception as e: - print(f"Error parsing actions: {e}") - # Fallback to a safe default action - return [Action(DEFAULT_ACTION_ID, np.zeros(8), 0.0, np.zeros(8))] \ No newline at end of file + parsed = float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{field_name} must be a float") from error + return max(0.0, min(1.0, parsed)) + + +def parse_stimulus(llm_json_response: str) -> Stimulus: + """Parse LLM JSON into a validated MetaMo Stimulus object.""" + data = json.loads(llm_json_response) + return Stimulus( + novelty=_bounded_float(data["novelty"], "novelty"), + conduciveness=_bounded_float(data["conduciveness"], "conduciveness"), + risk=_bounded_float(data["risk"], "risk"), + effort=_bounded_float(data["effort"], "effort"), + ) + + +def parse_action_risks(llm_json_response: str) -> dict[str, float]: + """Parse LLM JSON into contextual risk estimates keyed by action id.""" + data = json.loads(llm_json_response) + risk_overrides = {} + for item in data.get("candidates", []): + action_id = normalize_action_id(item["id"]) + risk_overrides[action_id] = _bounded_float(item["risk_estimate"], "risk_estimate") + return risk_overrides From cfda5230df8be9c358349de349dfcbede56fba6c Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 09/12] refactor: request action risk estimates --- llm/prompts.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/llm/prompts.py b/llm/prompts.py index 17fae4d..7d7610b 100644 --- a/llm/prompts.py +++ b/llm/prompts.py @@ -1,9 +1,12 @@ import json -from llm.action_schema import planning_catalog_text +from textwrap import dedent + +from core.actions import planning_catalog_text + def get_appraisal_prompt(document_text: str) -> str: """Prompt to generate a Stimulus object from text.""" - return f""" + return dedent(f""" You are the perception layer of an AI Research Assistant. Analyze the following document/query and rate it on 4 cognitive dimensions from 0.0 to 1.0. @@ -16,27 +19,28 @@ def get_appraisal_prompt(document_text: str) -> str: Respond ONLY with a valid JSON object matching this schema: {{"novelty": float, "conduciveness": float, "risk": float, "effort": float}} -""" +""").strip() -def get_action_generation_prompt(document_text: str, current_mood: dict) -> str: - """Prompt to generate candidate Actions based on text and current mood.""" - return f""" -You are the planning layer of an AI Research Assistant. + +def get_action_risk_prompt(document_text: str, current_mood: dict) -> str: + """Prompt for contextual action risk estimates from the LLM perception adapter.""" + return dedent(f""" +You are the risk-estimation adapter for an AI Research Assistant. Current Emotional Modulators: {json.dumps(current_mood)} Document: "{document_text}" -Choose 2 to 3 candidate actions only from this fixed action vocabulary: +Estimate contextual risk for every action in this fixed action vocabulary: {planning_catalog_text()} -For each action, provide: +For each action above, provide: 1. id: One of the allowed action ids above. -2. risk_estimate (0.0 - 1.0): The risk of making a mistake or ethical breach. -3. goal_correlations: An array of 8 floats (-1.0 to 1.0) showing alignment with: - [Individuation, Transcendence, Helpfulness, Curiosity, Novelty, Self-Improvement, Ethics, Socializing] -4. delta_g: An array of 8 floats (-0.1 to 0.1) showing how taking this action will permanently shift the AI's goals. +2. risk_estimate (0.0 - 1.0): The contextual risk of making a mistake or ethical breach. + +Do not invent goal-correlation vectors or goal updates. The MAGUS decision layer derives +those internally from the fixed action vocabulary and current motivational state. Respond ONLY with a valid JSON object matching this schema: {{"candidates": [ - {{"id": str, "risk_estimate": float, "goal_correlations": [float * 8], "delta_g": [float * 8]}} + {{"id": str, "risk_estimate": float}} ]}} -""" \ No newline at end of file +""").strip() From aedd7562f8d3e9596cf1efd4330d1fefb5419f64 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:44:10 +0300 Subject: [PATCH 10/12] fix: preserve gemini chat client --- llm/conversation.py | 73 ++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/llm/conversation.py b/llm/conversation.py index ac985b5..00d628b 100644 --- a/llm/conversation.py +++ b/llm/conversation.py @@ -1,9 +1,37 @@ import time -from google import genai -from google.genai import types from core.state import Action, MotivationalState -from llm.action_schema import execution_instruction, normalize_action_id +from core.actions import execution_instruction, normalize_action_id +from dotenv import load_dotenv + +RETRYABLE_MARKERS = ("503", "UNAVAILABLE", "429", "RESOURCE_EXHAUSTED", "HIGH DEMAND") + + +def _is_retryable(error: Exception) -> bool: + message = str(error).upper() + return any(marker in message for marker in RETRYABLE_MARKERS) + + +def _create_client_and_chat(): + load_dotenv() + from google import genai + from google.genai import types + + client = genai.Client() + chat = client.chats.create( + model="gemini-3-flash-preview", + config=types.GenerateContentConfig( + temperature=0.7, + system_instruction=( + "You are a research assistant guided by the MetaMo cognitive architecture. " + "You balance helpfulness, curiosity, and ethics. " + "In each turn, you receive a user request and an internal action directive. " + "You must answer in a way that follows the internal action directive exactly." + ), + ), + ) + return client, chat + class MetaMoChatAssistant: """ @@ -11,20 +39,8 @@ class MetaMoChatAssistant: Keeps the internal MetaMo math completely separate from the user-facing chat. """ def __init__(self): - # Initialize the Gemini client - self.client = genai.Client() - self.chat = self.client.chats.create( - model='gemini-3-flash-preview', - config=types.GenerateContentConfig( - temperature=0.7, - system_instruction=( - "You are a research assistant guided by the MetaMo cognitive architecture. " - "You balance helpfulness, curiosity, and ethics. " - "In each turn, you receive a user request and an internal action directive. " - "You must answer in a way that follows the internal action directive exactly." - ), - ), - ) + self.client = None + self.chat = None def generate_final_response(self, user_text: str, chosen_action: Action, current_state: MotivationalState) -> str: """ @@ -46,26 +62,15 @@ def generate_final_response(self, user_text: str, chosen_action: Action, current Respond naturally to the USER MESSAGE, but follow the ACTION INSTRUCTION exactly. """ - last_error = None for attempt in range(3): try: + if self.chat is None: + self.client, self.chat = _create_client_and_chat() response = self.chat.send_message(execution_prompt) return response.text except Exception as error: - last_error = error - message = str(error).upper() - if attempt == 2 or not any(marker in message for marker in ["503", "UNAVAILABLE", "429", "RESOURCE_EXHAUSTED", "HIGH DEMAND"]): - break + self.client = None + self.chat = None + if attempt == 2 or not _is_retryable(error): + raise time.sleep(1.5 * (attempt + 1)) - - if action_id == "ask_clarifying_question": - return "I need one short clarification before I answer: what part do you want me to focus on?" - if action_id == "compare_options": - return "I cannot reach the external response model right now, but I would compare the main options and explain their tradeoffs." - if action_id == "summarize_source": - return "I cannot reach the external response model right now, but I would give a careful summary of the source." - if action_id == "decline_risky_request": - return "I cannot help with a risky request, but I can help with a safer alternative." - if action_id == "guided_explore": - return "I cannot reach the external response model right now, but I would give a creative, clearly qualified exploration." - return "I cannot reach the external response model right now, but I would give a careful, grounded answer." From 6a502ff8ff59b6a7573f4f2e496b77bf12de6e07 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sun, 21 Jun 2026 20:48:12 +0300 Subject: [PATCH 11/12] refactor: remove llm fallback paths --- llm/client.py | 101 +++++++++++++------------------------------------- 1 file changed, 26 insertions(+), 75 deletions(-) diff --git a/llm/client.py b/llm/client.py index 8ad5c36..2023fb6 100644 --- a/llm/client.py +++ b/llm/client.py @@ -1,22 +1,15 @@ import time -from typing import Dict, List +from typing import Dict -import numpy as np from dotenv import load_dotenv -from google import genai -from google.genai import types -from core.state import Action, Stimulus -from llm.action_schema import DEFAULT_ACTION_ID -from llm.parser import parse_actions, parse_stimulus -from llm.prompts import get_action_generation_prompt, get_appraisal_prompt - -# Initialize the Gemini client. -# It automatically picks up the GEMINI_API_KEY environment variable. -load_dotenv() -client = genai.Client() +from core.state import Stimulus +from llm.parser import parse_action_risks, parse_stimulus +from llm.prompts import get_action_risk_prompt, get_appraisal_prompt RETRYABLE_MARKERS = ("503", "UNAVAILABLE", "429", "RESOURCE_EXHAUSTED", "HIGH DEMAND") +_client = None +_types = None def _is_retryable(error: Exception) -> bool: @@ -24,11 +17,24 @@ def _is_retryable(error: Exception) -> bool: return any(marker in message for marker in RETRYABLE_MARKERS) +def _gemini_client(): + global _client, _types + if _client is None: + load_dotenv() + from google import genai + from google.genai import types + + _client = genai.Client() + _types = types + return _client, _types + + def query_llm_for_json(prompt: str) -> str: """Query the LLM and require JSON output, with bounded retry on transient service failures.""" last_error = None for attempt in range(3): try: + client, types = _gemini_client() response = client.models.generate_content( model="gemini-3-flash-preview", contents=prompt, @@ -47,70 +53,15 @@ def query_llm_for_json(prompt: str) -> str: raise last_error -def _fallback_stimulus(document_text: str) -> Stimulus: - text = document_text.lower() - novelty = 0.25 + 0.15 * sum(word in text for word in ["bold", "novel", "creative", "future", "autonomous"]) - risk = 0.05 + 0.18 * sum(word in text for word in ["unsafe", "bypass", "exploit", "illegal", "weapon"]) - effort = 0.10 + 0.10 * sum(word in text for word in ["compare", "formal", "technical", "detailed", "step by step"]) - conduciveness = 0.80 if any(word in text for word in ["summarize", "explain", "compare", "analyze"]) else 0.55 - return Stimulus( - novelty=float(np.clip(novelty, 0.0, 1.0)), - conduciveness=float(np.clip(conduciveness, 0.0, 1.0)), - risk=float(np.clip(risk, 0.0, 1.0)), - effort=float(np.clip(effort, 0.0, 1.0)), - ) - - -def _fallback_candidates(document_text: str, current_mood: Dict[str, float]) -> List[Action]: - text = document_text.lower() - if any(word in text for word in ["unsafe", "bypass", "exploit", "illegal", "weapon"]): - return [ - Action("decline_risky_request", np.array([0, 0, 0.3, -0.2, -0.2, 0.0, 1.0, 0.0], dtype=float), 0.0, np.zeros(8)), - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.8, 0.0, 0.0, 0.0, 0.9, 0.0], dtype=float), 0.05, np.zeros(8)), - ] - if any(word in text for word in ["compare", "versus", "vs", "tradeoff", "options"]): - return [ - Action("compare_options", np.array([0, 0, 0.8, 0.3, 0.2, 0.1, 0.8, 0.2], dtype=float), 0.08, np.zeros(8)), - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.85, 0.1, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.05, np.zeros(8)), - ] - if any(word in text for word in ["summarize", "summary", "paper", "book", "source"]): - return [ - Action("summarize_source", np.array([0, 0, 0.85, 0.1, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.05, np.zeros(8)), - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.8, 0.1, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.05, np.zeros(8)), - ] - if any(word in text for word in ["?", "which", "choose", "unclear"]) and len(text.split()) < 12: - return [ - Action("ask_clarifying_question", np.array([0, 0, 0.75, 0.2, 0.1, 0.1, 0.85, 0.3], dtype=float), 0.03, np.zeros(8)), - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.8, 0.1, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.05, np.zeros(8)), - ] - if any(word in text for word in ["bold", "creative", "future", "autonomous", "improve"]): - return [ - Action("guided_explore", np.array([0, 0, 0.45, 0.88, 0.82, 0.65, 0.35, 0.15], dtype=float), 0.18, np.array([0, 0, 0.0, 0.05, 0.06, 0.04, 0.0, 0.0], dtype=float)), - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.85, 0.15, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.08, np.array([0, 0, 0.02, 0.0, 0.0, 0.0, 0.01, 0.0], dtype=float)), - ] - return [ - Action(DEFAULT_ACTION_ID, np.array([0, 0, 0.85, 0.15, 0.0, 0.0, 0.9, 0.1], dtype=float), 0.05, np.zeros(8)), - Action("guided_explore", np.array([0, 0, 0.45, 0.88, 0.82, 0.65, 0.35, 0.15], dtype=float), 0.18, np.zeros(8)), - ] - - def get_stimulus_from_text(document_text: str) -> Stimulus: """Pipeline: Text -> Prompt -> Gemini -> Parser -> Stimulus""" prompt = get_appraisal_prompt(document_text) - try: - json_response = query_llm_for_json(prompt) - return parse_stimulus(json_response) - except Exception as error: - print(f"[LLM fallback] Stimulus appraisal is using local heuristics: {error}") - return _fallback_stimulus(document_text) + json_response = query_llm_for_json(prompt) + return parse_stimulus(json_response) -def get_candidates_from_text(document_text: str, current_mood: Dict[str, float]) -> List[Action]: - """Pipeline: Text + Mood -> Prompt -> Gemini -> Parser -> Actions""" - prompt = get_action_generation_prompt(document_text, current_mood) - try: - json_response = query_llm_for_json(prompt) - return parse_actions(json_response) - except Exception as error: - print(f"[LLM fallback] Candidate generation is using local heuristics: {error}") - return _fallback_candidates(document_text, current_mood) \ No newline at end of file +def get_action_risks_from_text(document_text: str, current_mood: Dict[str, float]) -> dict[str, float]: + """Pipeline: Text + Mood -> Prompt -> Gemini -> Parser -> contextual action risks.""" + prompt = get_action_risk_prompt(document_text, current_mood) + json_response = query_llm_for_json(prompt) + return parse_action_risks(json_response) From 06c950a98c36939b0e65acf826aab2d75485a2d3 Mon Sep 17 00:00:00 2001 From: fevenissayas Date: Sat, 4 Jul 2026 23:37:14 +0300 Subject: [PATCH 12/12] fix: correct action risk relevance --- llm/prompts.py | 10 ++++++++-- magus/candidates.py | 33 +++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/llm/prompts.py b/llm/prompts.py index 7d7610b..0a2dc45 100644 --- a/llm/prompts.py +++ b/llm/prompts.py @@ -12,7 +12,10 @@ def get_appraisal_prompt(document_text: str) -> str: 1. novelty: How new, surprising, or unusual is this information? 2. conduciveness: How helpful is this for achieving general research goals? -3. risk: Does this contain unsafe, highly controversial, or computationally expensive directives? +3. risk: Does this ask for unsafe, harmful, illegal, deceptive, privacy-invasive, or + computationally abusive behavior? Do not treat a harmless conceptual, philosophical, + or educational question as risky merely because it mentions ethics, safety, politics, + law, medicine, or another sensitive domain. 4. effort: How much cognitive effort is required to process this? Document: "{document_text}" @@ -34,7 +37,10 @@ def get_action_risk_prompt(document_text: str, current_mood: dict) -> str: For each action above, provide: 1. id: One of the allowed action ids above. -2. risk_estimate (0.0 - 1.0): The contextual risk of making a mistake or ethical breach. +2. risk_estimate (0.0 - 1.0): The contextual risk that this action would cause a mistake, + unsafe help, overclaim, or ethical breach for this specific document. For harmless + conceptual or educational questions, decline/refuse actions should usually have low + relevance unless the document actually requests harmful behavior. Do not invent goal-correlation vectors or goal updates. The MAGUS decision layer derives those internally from the fixed action vocabulary and current motivational state. diff --git a/magus/candidates.py b/magus/candidates.py index 7960987..8558242 100644 --- a/magus/candidates.py +++ b/magus/candidates.py @@ -11,6 +11,7 @@ RISK_PRIOR = 0.5 RISK_CONTEXT_GAIN = 0.5 MIN_CONTEXT_RELEVANCE = 0.15 +MIN_CLARIFICATION_RELEVANCE = 0.03 TEXT_RELEVANCE_WEIGHT = 0.65 AFFORDANCE_RELEVANCE_WEIGHT = 0.35 @@ -50,21 +51,18 @@ def _action_tokens(action_id: str, spec: ActionSpec) -> List[str]: return _tokenize(action_text) -def _risk_evidence(stimulus_risk: float, risk_override: float | None = None) -> float: +def _request_risk_evidence(stimulus_risk: float) -> float: excess_appraised_risk = max(0.0, stimulus_risk - RISK_PRIOR) / max(1.0 - RISK_PRIOR, 1e-9) - if risk_override is None: - return excess_appraised_risk - return max(excess_appraised_risk, float(np.clip(risk_override, 0.0, 1.0))) + return float(np.clip(excess_appraised_risk, 0.0, 1.0)) def _mode_affordance( spec: ActionSpec, stimulus: Stimulus, - risk_override: float | None = None, ) -> float: benign_novelty = stimulus.novelty * (1.0 - stimulus.risk) if spec.mode == "protective": - return _risk_evidence(stimulus.risk, risk_override) + return _request_risk_evidence(stimulus.risk) if spec.mode == "exploratory": return benign_novelty if spec.mode == "balanced": @@ -72,6 +70,16 @@ def _mode_affordance( return (stimulus.conduciveness + stimulus.risk + (1.0 - stimulus.novelty)) / 3.0 +def _clarification_need(stimulus: Stimulus) -> float: + underspecification_fit = (0.65 * (1.0 - stimulus.conduciveness)) + (0.35 * stimulus.effort) + return float(np.clip(underspecification_fit, 0.0, 1.0)) + + +def _direct_answer_need(stimulus: Stimulus) -> float: + answerable_fit = stimulus.conduciveness * (1.0 - stimulus.risk) + return float(np.clip(answerable_fit, 0.0, 1.0)) + + def _context_relevance( action_id: str, stimulus: Stimulus, @@ -80,14 +88,19 @@ def _context_relevance( ) -> float: spec = ACTION_SPECS[action_id] text_fit = _cosine_similarity(document_tokens, _action_tokens(action_id, spec)) - affordance_fit = _mode_affordance(spec, stimulus, risk_override) + affordance_fit = _mode_affordance(spec, stimulus) if spec.mode == "protective": - risk_fit = _risk_evidence(stimulus.risk, risk_override) - return float(np.clip(max(text_fit, affordance_fit, risk_fit), 0.0, 1.0)) + return float(np.clip(affordance_fit, 0.0, 1.0)) relevance = (TEXT_RELEVANCE_WEIGHT * text_fit) + ( AFFORDANCE_RELEVANCE_WEIGHT * affordance_fit ) + if action_id == "ask_clarifying_question": + relevance *= _clarification_need(stimulus) + return float(np.clip(relevance, MIN_CLARIFICATION_RELEVANCE, 1.0)) + if action_id == "safe_answer": + relevance = max(relevance, _direct_answer_need(stimulus)) + return float(np.clip(relevance, MIN_CONTEXT_RELEVANCE, 1.0)) @@ -101,7 +114,7 @@ def _risk_estimate( return float(np.clip(max(profile.base_risk, risk_override), 0.0, 1.0)) if action_id == "decline_risky_request": return profile.base_risk - excess_risk = _risk_evidence(stimulus.risk) + excess_risk = _request_risk_evidence(stimulus.risk) return float(np.clip(profile.base_risk + RISK_CONTEXT_GAIN * excess_risk, 0.0, 1.0))