morphic-engine-lukaswolfden
Version:
Advanced relationship analysis engine with psychological insights, momentum scoring, and seduction psychology framework
191 lines • 8.98 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.calculateMomentumMorphoScore = calculateMomentumMorphoScore;
exports.calculateRelationshipScore = calculateRelationshipScore;
exports.applyTimeDecay = applyTimeDecay;
// ===== Tunables (safe defaults; tweak to taste) =====
const MAX_NEG_AMP = 3.0; // ceiling for negativity amplification
const NEG_KNEE = -0.30; // where amplification begins
const NEG_SOFTNESS = 0.25; // smoothness of the curve (bigger = smoother)
const RECENT_HOURS = 24; // time-weight emphasis window
const SAMPLE_CAP = 50; // last N messages considered per update
const EARLY_PRIOR_K = 12; // Bayesian-ish guard: n/(n+K)
const MOMENTUM_SAT_AT = 50; // momentum reaches 1 by ~50 msgs
const STABILITY_SAT_AT = 300; // stability approaches max by ~300 msgs
const STABILITY_MAX = 0.985; // veterans barely move per tick
const POS_DELTA_CAP = 2.0; // per-update caps (points)
const ENG_DELTA_CAP = 1.5;
const EMP_DELTA_CAP = 1.2;
const AUTH_DELTA_CAP = 0.7;
const AUTH_GAIN_WEIGHT = 0.5; // authenticity grows slowly
const EMPATHY_PROSOCIAL_BONUS = 0.5; // small bump when prosocial cues present
// ===== Small helpers =====
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/** Smooth amplification that increases as sentiment drops below NEG_KNEE */
function negAmplification(s) {
if (s >= NEG_KNEE)
return 1.0;
// Map (-1..NEG_KNEE) → (1..MAX_NEG_AMP) smoothly
const x = clamp((NEG_KNEE - s) / (1 - Math.abs(NEG_KNEE)), 0, 1);
// Soft quadratic easing with softness control
const eased = Math.pow(x, 1 + NEG_SOFTNESS);
return 1 + (MAX_NEG_AMP - 1) * eased;
}
/** Time weight for recency (last RECENT_HOURS dominate) */
function timeWeight(createdAt, nowMs) {
const t = new Date(createdAt).getTime();
const hrs = clamp((nowMs - t) / (1000 * 60 * 60), 0, 1e6);
// Linear fade over RECENT_HOURS with a floor to avoid zeroing out
return Math.max(0.1, 1 - hrs / RECENT_HOURS);
}
/** Momentum grows with history; stability dampens deltas for veterans */
function momentumFactor(totalMsg) {
return clamp(totalMsg / MOMENTUM_SAT_AT, 0, 1);
}
function stabilityFactor(totalMsg) {
// approaches STABILITY_MAX asymptotically as history grows
const f = 1 - Math.exp(-totalMsg / STABILITY_SAT_AT);
return clamp(f * STABILITY_MAX, 0, STABILITY_MAX);
}
/** Early-sample guard (Bayesian prior): scales effect when evidence is small */
function evidenceScale(n, k = EARLY_PRIOR_K) {
return n / (n + k);
}
/** Light prosocial heuristic (no heavy NLP): reward positive valence with any emotions */
function prosocialCue(s) {
if (!s.emotions || s.emotions.length === 0)
return 0;
// only reward when valence is non-negative to avoid mistaking anger/fear for empathy
return s.score >= 0 ? EMPATHY_PROSOCIAL_BONUS : 0;
}
/** Consistency in [0,1], higher = steadier behavior */
function consistency01(scores) {
if (scores.length < 2)
return 0;
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const varRaw = scores.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / scores.length;
// Normalize variance by theoretical max (score range is [-1,1], worst-case variance ~1)
const normVar = clamp(varRaw, 0, 1);
return 1 - normVar; // 1 = perfectly consistent, 0 = highly erratic
}
/** Safe sampler: last N messages that have sentiment */
function sampleRecent(arr, cap = SAMPLE_CAP) {
if (arr.length <= cap)
return arr;
return arr.slice(arr.length - cap);
}
// ===== Main: momentum-based MorphoScore =====
function calculateMomentumMorphoScore(previousScore, recentMessages, totalMessageCount) {
const base = previousScore ?? {
overall: 50, positivity: 50, engagement: 50, empathy: 50, authenticity: 50
};
const now = Date.now();
const sampled = sampleRecent(recentMessages, SAMPLE_CAP).filter(m => m.sentiment);
const n = sampled.length;
if (n === 0)
return base;
// One O(n) pass: accumulate weighted metrics
let posDelta = 0;
let engDelta = 0;
let empDelta = 0;
const posScores = [];
const evidScale = evidenceScale(n);
const mFactor = momentumFactor(totalMessageCount);
const stab = stabilityFactor(totalMessageCount);
const changeRate = 1 - stab;
for (const m of sampled) {
const s = m.sentiment;
const val = clamp(s.score, -1, 1);
const eng = clamp(s.engagement ?? 0, 0, 1);
const w = timeWeight(m.createdAt, now) * (0.5 + 0.5 * mFactor); // couple to momentum (active users weigh more)
// Smooth negative amplification (recency-aware via w)
const amp = negAmplification(val);
posDelta += val * amp * w;
// Engagement: prefer normalized, cheap features only
engDelta += eng * w;
// Empathy: small bonus when emotions present & valence non-negative
empDelta += prosocialCue(s) * w;
posScores.push(val);
}
// Normalize aggregate deltas by total weight (approximate with n)
const norm = n > 0 ? (1 / Math.sqrt(n)) : 1; // diminish volatility with more samples
posDelta *= norm * evidScale * changeRate;
engDelta *= norm * evidScale * changeRate * 0.5; // engagement swings milder than positivity
empDelta *= norm * evidScale * changeRate * 0.4; // empathy grows slowly
// Apply per-tick caps for human-stable feel
const newPos = clamp(base.positivity + clamp(posDelta * 10, -POS_DELTA_CAP, POS_DELTA_CAP), 0, 100);
const newEng = clamp(base.engagement + clamp(engDelta * 10, -ENG_DELTA_CAP, ENG_DELTA_CAP), 0, 100);
const newEmp = clamp(base.empathy + clamp(empDelta * 10, -EMP_DELTA_CAP, EMP_DELTA_CAP), 0, 100);
// Authenticity: grows with consistency, never punished downward here
const cons = consistency01(posScores);
const authDelta = cons * evidScale * changeRate * AUTH_GAIN_WEIGHT;
const newAuth = clamp(base.authenticity + Math.min(authDelta * 10, AUTH_DELTA_CAP), 0, 100);
const overall = Math.round((newPos + newEng + newEmp + newAuth) / 4);
return {
overall,
positivity: Math.round(newPos),
engagement: Math.round(newEng),
empathy: Math.round(newEmp),
authenticity: Math.round(newAuth),
};
}
// ===== Relationship score with momentum & graceful negatives =====
function calculateRelationshipScore(currentScore, recentInteractions, user1Id, user2Id) {
if (!recentInteractions.length)
return { score: currentScore, momentum: 0 };
const u1 = recentInteractions.filter(i => i.userId === user1Id && i.sentiment);
const u2 = recentInteractions.filter(i => i.userId === user2Id && i.sentiment);
if (!u1.length || !u2.length)
return { score: currentScore, momentum: 0 };
const now = Date.now();
const samp = sampleRecent(recentInteractions, SAMPLE_CAP).filter(i => i.sentiment);
// Compute average valence per user
const avg = (xs) => xs.reduce((a, i) => a + clamp(i.sentiment.score, -1, 1), 0) / xs.length;
const u1Avg = avg(u1);
const u2Avg = avg(u2);
const avgVal = (u1Avg + u2Avg) / 2;
// Negative severity × recency based penalty (smooth, bounded)
let negPenalty = 0;
let recentPosPush = 0;
let negCount = 0;
for (const i of samp) {
const v = clamp(i.sentiment.score, -1, 1);
const w = timeWeight(i.createdAt ?? now, now);
if (v < NEG_KNEE) {
const amp = negAmplification(v);
negPenalty += Math.abs(v) * amp * w;
negCount++;
}
else if (v > 0) {
// reward consistent positive exchanges
recentPosPush += v * w;
}
}
// Convert aggregates to bounded deltas
// Negatives: one sharp incident hurts, but severity & recency matter
const negDrop = clamp(negPenalty * 10, 0, 18); // cap large drops
// Positives: gradual growth with diminishing returns
const strength = Math.min(u1.length, u2.length);
const posGain = clamp((avgVal > 0 ? avgVal : 0) * (2.2 * Math.log(1 + strength)) + recentPosPush * 0.8, 0.5, 9);
let scoreDelta = 0;
let momentum = 0;
if (negCount > 0) {
scoreDelta = -negDrop;
momentum = -Math.min(20, Math.round(negPenalty * 25)); // negative momentum proportional to penalty
}
else {
scoreDelta = posGain;
momentum = Math.min(10, Math.round(avgVal * 8 + strength * 0.5));
}
const newScore = clamp(Math.round(currentScore + scoreDelta), 0, 100);
return { score: newScore, momentum };
}
// ===== Time decay: EMA towards 50 after inactivity =====
function applyTimeDecay(score, daysSinceLastInteraction) {
if (daysSinceLastInteraction <= 1)
return score;
const alpha = clamp(0.06 + 0.01 * Math.min(daysSinceLastInteraction, 21), 0.06, 0.27); // faster after long idle
const target = 50;
return Math.round(score + alpha * (target - score));
}
//# sourceMappingURL=momentum-scoring.js.map