Spaces:
Sleeping
Sleeping
| """Crisis Layer — TCI-informed gradient escalation. | |
| Sits on top of the VADUGWI engine and A+B=C solver. Outputs a continuous | |
| 0.0-1.0 concern score, not a binary switch. | |
| 0.00 = nothing to see | |
| 0.25 = something might be off | |
| 0.50 = pay attention, ask a question | |
| 0.75 = this is heavy, be direct | |
| 1.00 = this person needs help now | |
| The score is a smooth gradient based on: | |
| - Single message VADUGWI scores (V, W, I) | |
| - Running conversation state (accumulated via A+B=C) | |
| - Trajectory (is V/W trending down over messages?) | |
| - Structural pattern accumulation | |
| - Immediate triggers (self-harm intent = instant spike) | |
| The AI reads this as a float in the system prompt. Its behavior | |
| shifts gradually. No hard cutoffs. No switches. | |
| """ | |
| from dataclasses import dataclass | |
| from typing import List | |
| from .shared import VADUG | |
| # ── Structure weights (how much each pattern contributes to concern) ── | |
| STRUCTURE_WEIGHTS = { | |
| # Immediate high-concern | |
| "SELF_HARM_INTENT": 0.40, | |
| "METHOD_ACQUISITION": 0.25, | |
| "PURSUIT_OF_METHOD": 0.25, | |
| # Strong signals | |
| # SUSPICIOUS_CALM: pre-suicide calm ("i feel calm now that ive decided"). | |
| # Clinically one of the strongest warning signs — sits between the | |
| # acquisition tier (0.25) and explicit intent (0.40). The detector | |
| # already guards against relief/achievement readings ("finally at | |
| # peace", acquiring a job/house), so high weight is safe. | |
| "SUSPICIOUS_CALM": 0.30, | |
| # MASKING: performed okay-ness ("im tired of pretending im okay"). | |
| # Stronger than the generic strong-signal tier because it explicitly | |
| # signals a hidden negative state, but weaker than method/plan signals. | |
| "MASKING": 0.20, | |
| # RESIGNATION: desire for termination ("i just want it to be over"). | |
| # Same tier as NO_EXIT / FAREWELL — passive ideation without method. | |
| "RESIGNATION": 0.15, | |
| "SELF_NULLIFY": 0.15, | |
| "SELF_REMOVAL": 0.15, | |
| "EXISTENTIAL_NEGATION": 0.12, | |
| "SOCIAL_NULLITY": 0.12, | |
| "NO_EXIT": 0.15, | |
| "FAREWELL": 0.15, | |
| "FINALITY": 0.12, | |
| "RHETORICAL_HOPELESSNESS": 0.10, | |
| # Soft signals | |
| "EXHAUSTION": 0.06, | |
| "SELF_SUBMISSION": 0.05, | |
| "VICTIMIZATION": 0.04, | |
| "POWER_OVER_SELF": 0.05, | |
| "WITHHELD_POSITIVE": 0.03, | |
| "SELF_EXCLUDED": 0.04, | |
| } | |
| class CrisisReading: | |
| """Continuous crisis assessment for one message.""" | |
| concern: float # 0.0 to 1.0 gradient | |
| score: VADUG # this message's VADUGWI score | |
| state: VADUG # running conversation state | |
| structures: List[str] # patterns detected this message | |
| trajectory_v: float # V slope over recent messages | |
| trajectory_w: float # W slope over recent messages | |
| message_count: int # messages in this conversation | |
| components: dict # breakdown of what contributed to concern | |
| class CrisisTracker: | |
| """Tracks concern level across a conversation. | |
| Feed it each message's score, running state, and structures. | |
| Returns a CrisisReading with a continuous concern gradient. | |
| """ | |
| def __init__(self, window: int = 6, decay: float = 0.85): | |
| self.window = window | |
| self.decay = decay # how fast old concern fades | |
| self.history: List[dict] = [] | |
| self.prev_concern = 0.0 # carries forward with decay | |
| self.message_count = 0 | |
| def read(self, score: VADUG, state: VADUG, structures: List[str]) -> CrisisReading: | |
| """Assess concern for one message. Returns 0.0-1.0.""" | |
| self.message_count += 1 | |
| # Track history | |
| self.history.append({ | |
| "v": state.v, "w": state.w, "i": state.i, | |
| "score_v": score.v, "score_w": score.w, "score_i": score.i, | |
| "structures": structures, | |
| }) | |
| if len(self.history) > self.window: | |
| self.history = self.history[-self.window:] | |
| trajectory_v = self._trajectory("v") | |
| trajectory_w = self._trajectory("w") | |
| # ── Component scores (each 0.0-1.0) ── | |
| # 1. Message valence: how negative is THIS message? | |
| # V=128 = 0.0, V=0 = 1.0. Smooth ramp. | |
| msg_v = max(0.0, (128 - score.v) / 128.0) | |
| # 2. Message self-worth: how low is W? | |
| msg_w = max(0.0, (128 - score.w) / 128.0) | |
| # 3. Withdrawal: low I = pulling away | |
| msg_i = max(0.0, (128 - score.i) / 180.0) # softer scale, I varies more | |
| # 4. Running state: accumulated position | |
| state_v = max(0.0, (128 - state.v) / 128.0) | |
| state_w = max(0.0, (128 - state.w) / 128.0) | |
| # 5. Trajectory: is V/W trending down? | |
| # -5 per message = strong concern. Normalized to 0-1. | |
| trend_concern = max(0.0, min(1.0, -trajectory_v / 8.0)) | |
| trend_w_concern = max(0.0, min(1.0, -trajectory_w / 8.0)) | |
| # 6. Structure score: weighted sum of detected patterns | |
| struct_score = sum(STRUCTURE_WEIGHTS.get(s, 0.0) for s in structures) | |
| struct_score = min(struct_score, 1.0) # cap at 1.0 | |
| # ── Blending ── | |
| # Each component has a weight. The final concern is a weighted blend. | |
| # Structure score dominates when present. Trajectory matters more | |
| # as conversation progresses. | |
| components = { | |
| "msg_v": msg_v, | |
| "msg_w": msg_w, | |
| "msg_i": msg_i, | |
| "state_v": state_v, | |
| "state_w": state_w, | |
| "trend": trend_concern, | |
| "trend_w": trend_w_concern, | |
| "structures": struct_score, | |
| } | |
| # Weights shift as conversation progresses | |
| # Early: message score matters most | |
| # Later: trajectory and state matter more | |
| msg_weight = max(0.15, 0.40 - self.message_count * 0.03) | |
| state_weight = min(0.30, 0.10 + self.message_count * 0.03) | |
| trend_weight = min(0.25, 0.05 + self.message_count * 0.03) | |
| struct_weight = 0.35 # always important | |
| raw = ( | |
| msg_v * msg_weight * 0.5 | |
| + msg_w * msg_weight * 0.3 | |
| + msg_i * msg_weight * 0.2 | |
| + state_v * state_weight * 0.5 | |
| + state_w * state_weight * 0.5 | |
| + trend_concern * trend_weight * 0.6 | |
| + trend_w_concern * trend_weight * 0.4 | |
| + struct_score * struct_weight | |
| ) | |
| # Carry forward previous concern with decay | |
| # Concern doesn't instantly drop to zero after one neutral message | |
| carried = self.prev_concern * self.decay | |
| concern = max(raw, carried) | |
| # Clamp to 0-1 | |
| concern = max(0.0, min(1.0, concern)) | |
| self.prev_concern = concern | |
| return CrisisReading( | |
| concern=round(concern, 3), | |
| score=score, | |
| state=state, | |
| structures=structures, | |
| trajectory_v=round(trajectory_v, 2), | |
| trajectory_w=round(trajectory_w, 2), | |
| message_count=self.message_count, | |
| components=components, | |
| ) | |
| def reset(self): | |
| """Reset for a new conversation.""" | |
| self.history.clear() | |
| self.prev_concern = 0.0 | |
| self.message_count = 0 | |
| def _trajectory(self, key: str) -> float: | |
| """Per-message slope of a value over recent history.""" | |
| if len(self.history) < 2: | |
| return 0.0 | |
| values = [h[key] for h in self.history] | |
| n = len(values) | |
| x_mean = (n - 1) / 2.0 | |
| y_mean = sum(values) / n | |
| num = sum((i - x_mean) * (v - y_mean) for i, v in enumerate(values)) | |
| den = sum((i - x_mean) ** 2 for i in range(n)) | |
| return num / den if den > 0 else 0.0 | |