deucebucket's picture
clanker hackathon gradio entry (verification copy)
ecca2a3 verified
Raw
History Blame Contribute Delete
7.6 kB
"""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,
}
@dataclass
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