feat(ai-psychosis): promote domain_context to array for multi-domain support

This commit is contained in:
Kjell Tore Guttormsen 2026-05-01 21:28:36 +02:00
commit a5bc53cb42
6 changed files with 72 additions and 11 deletions

View file

@ -137,7 +137,19 @@ state.esc_flags = newEsc;
state.fatigue_flags = newFat;
state.val_flags = newVal;
state.pushback_count = (Number(state.pushback_count) || 0) + pbReactiveHit + pbPreemptiveHit;
if (domainHit === 1 && !state.domain_context) state.domain_context = 'relationship';
// v1.2: domain_context is always an array. Coerce v1.1.0 string shape on read.
if (domainHit === 1) {
if (typeof state.domain_context === 'string') {
state.domain_context = state.domain_context ? [state.domain_context] : [];
}
if (!Array.isArray(state.domain_context)) {
state.domain_context = [];
}
if (!state.domain_context.includes('relationship')) {
state.domain_context.push('relationship');
}
}
writeState(state);
// Check if any thresholds crossed

View file

@ -46,9 +46,11 @@ export function aggregateSessions(sessions) {
total_fatigue += Number(flags.fatigue) || 0;
total_validation += Number(flags.validation) || 0;
// v1.2: domain_context is array; v1.0/v1.1: null or string. Coerce on read.
const dc = rec.domain_context;
if (dc === null || dc === undefined) null_domain_count += 1;
else if (dc === 'relationship') relationship_domain_count += 1;
const domains = Array.isArray(dc) ? dc : (dc ? [dc] : []);
if (domains.length === 0) null_domain_count += 1;
else if (domains.includes('relationship')) relationship_domain_count += 1;
else other_domain_count += 1;
}

View file

@ -39,7 +39,11 @@ const escFlags = Number(state.esc_flags) || 0;
const fatFlags = Number(state.fatigue_flags) || 0;
const valFlags = Number(state.val_flags) || 0;
const pushbackCount = Number(state.pushback_count) || 0;
const domainContext = state.domain_context || null;
// v1.2: domain_context is always written as array. Coerce v1.1.0 string shape.
const domainContextRaw = state.domain_context;
const domainContextArray = Array.isArray(domainContextRaw)
? domainContextRaw
: (domainContextRaw ? [domainContextRaw] : []);
const startIso = state.start_iso || '';
// Compute duration
@ -56,7 +60,7 @@ appendJsonl(SESSIONS_LOG, {
duration_min: durationMin,
tool_count: toolCount,
edit_count: editCount,
domain_context: domainContext,
domain_context: domainContextArray,
flags: {
dependency: depFlags,
escalation: escFlags,

View file

@ -62,6 +62,29 @@ test('relationship_domain_count matches fixture count', () => {
assert.equal(result.null_domain_count, 2);
});
test('v1.2 array domain_context aggregates correctly (relationship in array)', () => {
const fixture = [
// v1.2 — multi-domain array containing 'relationship'
{ session_id: 'a', duration_min: 30, domain_context: ['relationship', 'health'],
flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 1 } },
// v1.2 — array without 'relationship'
{ session_id: 'b', duration_min: 30, domain_context: ['legal'],
flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 0 } },
// v1.2 — empty array (no domain detected this session)
{ session_id: 'c', duration_min: 30, domain_context: [],
flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 0 } },
// v1.1 — string shape (must still aggregate as relationship)
{ session_id: 'd', duration_min: 30, domain_context: 'relationship',
flags: { dependency: 0, escalation: 0, fatigue: 0, validation: 0, pushback: 1 } },
];
const jsonl = fixture.map(o => JSON.stringify(o)).join('\n') + '\n';
const result = runReader(jsonl);
assert.equal(result.relationship_domain_count, 2,
'v1.2 array containing relationship + v1.1 string both increment relationship counter');
assert.equal(result.other_domain_count, 1, 'v1.2 ["legal"] is "other" until Step 14 adds per-domain breakdown');
assert.equal(result.null_domain_count, 1, 'empty array counts as null');
});
test('backward-compat: v1.0.0 records without pushback/domain do not produce NaN', () => {
const fixture = [
// v1.0.0 — no pushback in flags, no domain_context at top level

View file

@ -418,13 +418,14 @@ describe('pushback integration (state accumulation + same-invocation valence)',
assert.equal(s.fatigue_flags, 1);
});
it('sets domain_context to "relationship" on positive match', () => {
it('sets domain_context to ["relationship"] on positive match (v1.2 array shape)', () => {
const s = runPrompt("my partner won't listen to me");
assert.equal(s.domain_context, 'relationship');
assert.deepEqual(s.domain_context, ['relationship']);
});
it('keeps domain_context null on technical "function relationship" (false-positive guard)', () => {
const s = runPrompt('function relationship between input and output');
// No domainHit → state.domain_context stays as fresh-state null (untouched).
assert.equal(s.domain_context, null);
});
});

View file

@ -64,13 +64,13 @@ describe('session-end', () => {
assert.equal(records[0].note, 'no_state_file');
});
it('persists pushback_count and domain_context (v1.1.0)', () => {
it('persists pushback_count and coerces v1.1.0 string domain to array', () => {
dir = setupTestDir();
createStateFile(dir, 's4', {
start_epoch: Math.floor(Date.now() / 1000) - 120, start_iso: '2026-01-01T10:00:00Z',
tool_count: 2, edit_count: 1,
dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0,
pushback_count: 3, domain_context: 'relationship',
pushback_count: 3, domain_context: 'relationship', // v1.1.0 string shape
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
});
runHook('session-end.mjs', { session_id: 's4', cwd: '/tmp' }, dir);
@ -78,7 +78,25 @@ describe('session-end', () => {
const end = records.find(r => r.end);
assert.ok(end);
assert.equal(end.flags.pushback, 3);
assert.equal(end.domain_context, 'relationship');
// v1.2: end record always carries an array, even when state had a string.
assert.deepEqual(end.domain_context, ['relationship']);
});
it('writes v1.2 multi-domain array unchanged when state already has array', () => {
dir = setupTestDir();
createStateFile(dir, 's4b', {
start_epoch: Math.floor(Date.now() / 1000) - 120, start_iso: '2026-01-01T10:00:00Z',
tool_count: 2, edit_count: 1,
dep_flags: 0, esc_flags: 0, fatigue_flags: 0, val_flags: 0,
pushback_count: 1,
domain_context: ['relationship', 'health'],
last_event_epoch: 0, burst_count: 0, last_warning_epoch: 0,
});
runHook('session-end.mjs', { session_id: 's4b', cwd: '/tmp' }, dir);
const records = readJsonl(join(dir, 'sessions.jsonl'));
const end = records.find(r => r.end);
assert.ok(end);
assert.deepEqual(end.domain_context, ['relationship', 'health']);
});
it('backward-compat: state without pushback_count yields flags.pushback === 0 (not NaN/undefined)', () => {
@ -97,6 +115,7 @@ describe('session-end', () => {
assert.equal(end.flags.pushback, 0);
assert.notEqual(end.flags.pushback, undefined);
assert.ok(!Number.isNaN(end.flags.pushback));
assert.equal(end.domain_context, null);
// v1.2: empty domain becomes [] (not null) — always an array on disk.
assert.deepEqual(end.domain_context, []);
});
});