Skip to content

Commit b54c9d1

Browse files
authored
Merge pull request #19 from firstsun-dev/worktree-personality-scoring-fix
fix(personality): relative-frequency cognitive-function scoring + opt-in LLM-vote mode
2 parents 5837c69 + 2da35ae commit b54c9d1

14 files changed

Lines changed: 610 additions & 50 deletions

File tree

cli/src/analysis/__tests__/personality.test.ts

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -294,19 +294,24 @@ const PATTERN_TO_FUNCTION: Record<string, string> = {
294294
};
295295

296296
describe('computePersonalityProfile — cognitive functions', () => {
297-
it('scores all 8 functions from mean confidence of their mapped pattern category', () => {
297+
it('scores each function by its relative share of pattern instances, not mean confidence', () => {
298+
// 8 instances total: ni gets 2 (2x the 1/8 "fair share" -> capped at 100), six other
299+
// functions get exactly 1 each (exactly fair share -> 50/moderate), fe gets 0 (-> null).
300+
// Confidence is deliberately uniform (all 80) to prove the score no longer tracks it —
301+
// this is the fix for the old formula's "everything lands >=65 because confidence has a
302+
// 70 floor" bug (see computeCognitiveFunctions' doc comment in ../personality.ts).
298303
const facets: PersonalityFacetInput[] = [
299304
facet({
300305
effectivePatterns: [
301-
ep({ category: 'structured-planning', confidence: 90 }),
302-
ep({ category: 'structured-planning', confidence: 70 }), // ni: (90+70)/2 = 80
303-
ep({ category: 'context-gathering', confidence: 60 }), // ne: 60
304-
ep({ category: 'domain-expertise', confidence: 40 }), // si: 40
305-
ep({ category: 'incremental-implementation', confidence: 100 }), // se: 100
306-
ep({ category: 'systematic-debugging', confidence: 55 }), // ti: 55
307-
ep({ category: 'verification-workflow', confidence: 65 }), // te: 65
308-
ep({ category: 'self-correction', confidence: 20 }), // fi: 20
309-
ep({ category: 'effective-tooling', confidence: 75 }), // fe: 75
306+
ep({ category: 'structured-planning', confidence: 80 }), // ni
307+
ep({ category: 'structured-planning', confidence: 80 }), // ni (2nd instance)
308+
ep({ category: 'context-gathering', confidence: 80 }), // ne
309+
ep({ category: 'domain-expertise', confidence: 80 }), // si
310+
ep({ category: 'incremental-implementation', confidence: 80 }), // se
311+
ep({ category: 'systematic-debugging', confidence: 80 }), // ti
312+
ep({ category: 'verification-workflow', confidence: 80 }), // te
313+
ep({ category: 'self-correction', confidence: 80 }), // fi
314+
// effective-tooling (fe) deliberately absent
310315
],
311316
}),
312317
];
@@ -316,15 +321,44 @@ describe('computePersonalityProfile — cognitive functions', () => {
316321
// Stable order check
317322
expect(profile.cognitiveFunctions.map(f => f.key)).toEqual(['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']);
318323

319-
expect(cogFn(profile.cognitiveFunctions, 'ni').score).toBe(80);
324+
expect(cogFn(profile.cognitiveFunctions, 'ni').score).toBe(100);
320325
expect(cogFn(profile.cognitiveFunctions, 'ni').sampleSize).toBe(2);
321-
expect(cogFn(profile.cognitiveFunctions, 'ne').score).toBe(60);
322-
expect(cogFn(profile.cognitiveFunctions, 'si').score).toBe(40);
323-
expect(cogFn(profile.cognitiveFunctions, 'se').score).toBe(100);
324-
expect(cogFn(profile.cognitiveFunctions, 'ti').score).toBe(55);
325-
expect(cogFn(profile.cognitiveFunctions, 'te').score).toBe(65);
326-
expect(cogFn(profile.cognitiveFunctions, 'fi').score).toBe(20);
327-
expect(cogFn(profile.cognitiveFunctions, 'fe').score).toBe(75);
326+
for (const key of ['ne', 'si', 'se', 'ti', 'te', 'fi']) {
327+
expect(cogFn(profile.cognitiveFunctions, key).score).toBe(50);
328+
expect(cogFn(profile.cognitiveFunctions, key).sampleSize).toBe(1);
329+
}
330+
331+
const fe = cogFn(profile.cognitiveFunctions, 'fe');
332+
expect(fe.score).toBeNull();
333+
expect(fe.sampleSize).toBe(0);
334+
expect(fe.band).toBeUndefined();
335+
});
336+
337+
it('differentiates functions purely by relative frequency even when confidence is identical', () => {
338+
// se: 2 instances, fi: 1 instance, plus 6 one-off fillers spread across the other
339+
// categories so neither se nor fi's share hits the 2x-fair-share cap (which would make
340+
// both saturate at 100 and hide the ordering this test is checking for).
341+
const facets: PersonalityFacetInput[] = [
342+
facet({
343+
effectivePatterns: [
344+
ep({ category: 'incremental-implementation', confidence: 95 }), // se
345+
ep({ category: 'incremental-implementation', confidence: 95 }), // se
346+
ep({ category: 'self-correction', confidence: 95 }), // fi
347+
ep({ category: 'structured-planning', confidence: 95 }), // ni filler
348+
ep({ category: 'context-gathering', confidence: 95 }), // ne filler
349+
ep({ category: 'domain-expertise', confidence: 95 }), // si filler
350+
ep({ category: 'systematic-debugging', confidence: 95 }), // ti filler
351+
ep({ category: 'verification-workflow', confidence: 95 }), // te filler
352+
ep({ category: 'effective-tooling', confidence: 95 }), // fe filler
353+
],
354+
}),
355+
];
356+
const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__');
357+
const se = cogFn(profile.cognitiveFunctions, 'se');
358+
const fi = cogFn(profile.cognitiveFunctions, 'fi');
359+
expect(se.score).not.toBeNull();
360+
expect(fi.score).not.toBeNull();
361+
expect(se.score!).toBeGreaterThan(fi.score!);
328362
});
329363

330364
it('is null with sampleSize 0 for a function whose category has zero pattern instances', () => {
@@ -338,15 +372,29 @@ describe('computePersonalityProfile — cognitive functions', () => {
338372
expect(fe.band).toBeUndefined();
339373
});
340374

341-
it('maps each of the 8 known categories to its documented function independently', () => {
375+
it('scores an isolated single-category sample at 100 — its entire share of the total', () => {
342376
for (const [category, fn] of Object.entries(PATTERN_TO_FUNCTION)) {
343377
const facets: PersonalityFacetInput[] = [
344378
facet({ effectivePatterns: [ep({ category, confidence: 88 })] }),
345379
];
346380
const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__');
347-
expect(cogFn(profile.cognitiveFunctions, fn).score).toBe(88);
381+
expect(cogFn(profile.cognitiveFunctions, fn).score).toBe(100);
348382
}
349383
});
384+
385+
it('returns all-null functions when there are zero effective pattern instances', () => {
386+
const facets: PersonalityFacetInput[] = [facet({ effectivePatterns: [] })];
387+
const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__');
388+
for (const f of profile.cognitiveFunctions) {
389+
expect(f.score).toBeNull();
390+
expect(f.sampleSize).toBe(0);
391+
}
392+
});
393+
394+
it('sets cognitiveFunctionScoringMode to formula (the only mode this pure function knows about)', () => {
395+
const profile = computePersonalityProfile([facet()], [], '2026-W29', '__all__');
396+
expect(profile.cognitiveFunctionScoringMode).toBe('formula');
397+
});
350398
});
351399

352400
// ── MBTI derivation ──────────────────────────────────────────────────────────

cli/src/analysis/personality.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@
55
// the server route (server/src/routes/personality.ts) import this module — no
66
// reimplementation of scoring logic in either caller.
77
//
8-
// The LLM is used ONLY for the optional `archetype` prose (see server/src/llm/
9-
// reflect-prompts.ts generatePersonalityPrompt). Every numeric field on
10-
// PersonalityProfile produced here is deterministic and reproducible from the same
11-
// inputs — the LLM never contributes a number to this profile.
8+
// Every numeric field THIS MODULE produces is deterministic and reproducible from the
9+
// same inputs — this file itself never calls an LLM. Two callers layer optional
10+
// LLM-authored numbers on top of what this module computes, both in server/src/llm/:
11+
// - reflect-prompts.ts generatePersonalityPrompt: the `archetype` prose plus
12+
// `mbti.topCandidates[].likelihood`, a top-5 ranked MBTI guess.
13+
// - personality-vote.ts scoreCognitiveFunctionsByLlmVote: an OPT-IN alternative to
14+
// computeCognitiveFunctions below, gated on dashboard.analysis.personality.
15+
// cognitiveFunctionScoring === 'llm-vote' in config.json. When active, it replaces
16+
// `cognitiveFunctions` (and therefore `mbti`, re-derived from those scores) with the
17+
// average of N independent LLM scoring rounds instead of the formula in this file —
18+
// see PersonalityProfile.cognitiveFunctionScoringMode, which records which path ran.
19+
// computePersonalityProfile below always returns the deterministic 'formula' scores;
20+
// only the server route (POST /generate) can override them post hoc.
1221

1322
import type {
1423
FrictionPoint,
@@ -26,8 +35,12 @@ import type {
2635

2736
/** Formula version for the deterministic scoring below. Bump when any formula changes
2837
* so cached personality_snapshots rows can be identified as stale by consumers that care.
29-
* Bumped to 2.0.0 for the cognitiveFunctions + mbti addition (profileVersion 2). */
30-
export const PERSONALITY_ANALYSIS_VERSION = '2.0.0';
38+
* Bumped to 2.0.0 for the cognitiveFunctions + mbti addition (profileVersion 2).
39+
* Bumped to 2.1.0 when computeCognitiveFunctions switched from mean-confidence to
40+
* relative-frequency-share scoring (see that function's doc comment) — readSnapshot in
41+
* server/src/routes/personality.ts treats any cached row below this version as stale so
42+
* old confidence-scored rows get recomputed instead of served forever. */
43+
export const PERSONALITY_ANALYSIS_VERSION = '2.1.0';
3144

3245
/**
3346
* Per-session facet input. Deliberately a flattened, caller-friendly shape rather than
@@ -91,7 +104,7 @@ function normalizeConfidence(raw: number): number {
91104
return raw;
92105
}
93106

94-
function bandFor(score: number): 'low' | 'moderate' | 'high' {
107+
export function bandFor(score: number): 'low' | 'moderate' | 'high' {
95108
if (score >= 65) return 'high';
96109
if (score >= 35) return 'moderate';
97110
return 'low';
@@ -276,7 +289,7 @@ function computePace(facets: PersonalityFacetInput[]): PersonalityPace {
276289
// self-correction -> Fi (Introverted Feeling — internally-driven correction against one's own standard)
277290
// effective-tooling -> Fe (Extraverted Feeling — attunement to and effective use of the
278291
// external/collaborative environment)
279-
const EFFECTIVE_PATTERN_TO_FUNCTION: Record<string, CognitiveFunctionKey> = {
292+
export const EFFECTIVE_PATTERN_TO_FUNCTION: Record<string, CognitiveFunctionKey> = {
280293
'structured-planning': 'ni',
281294
'context-gathering': 'ne',
282295
'domain-expertise': 'si',
@@ -288,37 +301,62 @@ const EFFECTIVE_PATTERN_TO_FUNCTION: Record<string, CognitiveFunctionKey> = {
288301
};
289302

290303
/** Stable, fixed display/serialization order for the 8 cognitive functions. */
291-
const COGNITIVE_FUNCTION_ORDER: CognitiveFunctionKey[] = ['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe'];
304+
export const COGNITIVE_FUNCTION_ORDER: CognitiveFunctionKey[] = ['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe'];
292305

293306
/**
294-
* One score per Jungian cognitive function: mean confidence (normalized 0-100) of
295-
* effective-pattern instances whose category maps to that function, via
296-
* EFFECTIVE_PATTERN_TO_FUNCTION above. Same aggregation style as computeCraft — a flat
297-
* mean over all matching pattern instances, not a per-session average. Zero instances
298-
* for a function -> null score, sampleSize 0 (never defaults to 0/neutral — "no signal"
299-
* and "measured and low" are different things, same convention as every other score
300-
* in this file).
307+
* One score per Jungian cognitive function: RELATIVE FREQUENCY SHARE of effective-pattern
308+
* instances mapped to that function (via EFFECTIVE_PATTERN_TO_FUNCTION above) — NOT mean
309+
* confidence, despite that being the v1 (analysisVersion 2.0.0) formula.
310+
*
311+
* Why the change: effective-pattern confidence is written by the analysis prompts with a
312+
* hard floor of 70 ("Require a minimum confidence score of 70 for any decision or
313+
* learning. Drop insights below this threshold." — cli/src/analysis/prompts.ts) and
314+
* clusters in 70-95 in practice. Averaging confidence therefore made every function that
315+
* had any samples at all land at or above 65 (the "high" band threshold from bandFor)
316+
* almost by construction — it measured "how sure the LLM was when it flagged an
317+
* instance," not "how strongly this function shows up relative to the other 7." Jungian
318+
* functions (and MBTI more broadly) are an ipsative/competing construct by design —
319+
* strength in one function implies relatively less reliance on its opposite — so a score
320+
* that can't differentiate across the 8 functions can't produce a meaningful profile.
321+
*
322+
* Formula: share = count(function) / totalCount(all mapped instances in scope).
323+
* fairShare = 1 / 8 (the uniform baseline if all 8 functions were equally represented).
324+
* score = round(min(100, (share / fairShare) * 50)) — a function sitting exactly at its
325+
* fair share scores 50 (moderate); one at 2x fair share or more scores 100 (high,
326+
* capped); one entirely absent relative to the total scores toward 0. This directly
327+
* reflects "how often this function's behavior shows up relative to the others," which
328+
* is the actual claim a Jungian function score makes, and — unlike mean confidence —
329+
* guarantees differentiation across the 8 scores whenever pattern-category usage isn't
330+
* perfectly uniform (the overwhelmingly common case). Zero total instances -> every
331+
* function null (never a fabricated midpoint); zero instances for one function specifically
332+
* -> null score, sampleSize 0 for that function only (same "no signal" convention as every
333+
* other score in this file).
301334
*/
302335
function computeCognitiveFunctions(facets: PersonalityFacetInput[]): CognitiveFunctionScore[] {
303-
const sums = new Map<CognitiveFunctionKey, number>();
304336
const counts = new Map<CognitiveFunctionKey, number>();
337+
let totalCount = 0;
305338

306339
for (const facet of facets) {
307340
for (const ep of facet.effectivePatterns) {
308341
const fn = EFFECTIVE_PATTERN_TO_FUNCTION[ep.category];
309342
if (!fn) continue; // unmapped/unknown category — not one of the 8 known effective-pattern categories
310-
if (typeof ep.confidence !== 'number' || !Number.isFinite(ep.confidence)) continue;
311-
sums.set(fn, (sums.get(fn) ?? 0) + normalizeConfidence(ep.confidence));
312343
counts.set(fn, (counts.get(fn) ?? 0) + 1);
344+
totalCount++;
313345
}
314346
}
315347

348+
if (totalCount === 0) {
349+
return COGNITIVE_FUNCTION_ORDER.map(key => ({ key, score: null, sampleSize: 0 }));
350+
}
351+
352+
const fairShare = 1 / COGNITIVE_FUNCTION_ORDER.length;
316353
return COGNITIVE_FUNCTION_ORDER.map(key => {
317354
const count = counts.get(key) ?? 0;
318355
if (count === 0) {
319356
return { key, score: null, sampleSize: 0 };
320357
}
321-
const score = Math.round((sums.get(key) ?? 0) / count);
358+
const share = count / totalCount;
359+
const score = Math.round(Math.min(100, (share / fairShare) * 50));
322360
return { key, score, band: bandFor(score), sampleSize: count };
323361
});
324362
}
@@ -455,6 +493,7 @@ export function computePersonalityProfile(
455493
axis,
456494
pace,
457495
cognitiveFunctions,
496+
cognitiveFunctionScoringMode: 'formula',
458497
mbti,
459498
computedAt: new Date().toISOString(),
460499
analysisVersion: PERSONALITY_ANALYSIS_VERSION,

cli/src/commands/config.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ function showConfigAction(): void {
8484
console.log(chalk.gray(` Same-proj: ${r.sameProjectOnly !== false ? 'yes' : 'no'}`));
8585
}
8686

87+
// Personality — cognitive function scoring mode
88+
{
89+
const p = config.dashboard?.analysis?.personality;
90+
const mode = p?.cognitiveFunctionScoring ?? 'formula';
91+
console.log(chalk.white('\n Personality (cognitive functions):'));
92+
console.log(chalk.gray(` Scoring: ${mode}${mode === 'formula' ? ' (deterministic, default)' : ''}`));
93+
if (mode === 'llm-vote') {
94+
console.log(chalk.gray(` Vote rounds: ${p?.llmVoteRounds ?? 3}`));
95+
}
96+
}
97+
8798
// Telemetry — default is enabled; env vars can override at runtime
8899
console.log(chalk.white('\n Telemetry:'));
89100
const telemetryEnabled = config.telemetry !== false;
@@ -105,7 +116,7 @@ export const configCommand = new Command('config')
105116

106117
configCommand
107118
.command('set <key> <value>')
108-
.description('Set a configuration value (telemetry)')
119+
.description('Set a configuration value (telemetry, personality-scoring, personality-vote-rounds)')
109120
.action((key: string, value: string) => {
110121
if (key === 'telemetry') {
111122
if (value !== 'true' && value !== 'false') {
@@ -124,8 +135,52 @@ configCommand
124135
}
125136
console.log(chalk.green(`\nTelemetry ${value === 'true' ? 'enabled' : 'disabled'}.\n`));
126137
trackEvent('cli_config', { subcommand: 'set', success: true });
138+
} else if (key === 'personality-scoring') {
139+
if (value !== 'formula' && value !== 'llm-vote') {
140+
console.error(chalk.red(`\nInvalid value "${value}". Must be "formula" or "llm-vote".\n`));
141+
process.exit(1);
142+
}
143+
const existing = loadConfig() ?? { sync: { claudeDir: '~/.claude/projects', excludeProjects: [] } };
144+
existing.dashboard = {
145+
...existing.dashboard,
146+
analysis: {
147+
...existing.dashboard?.analysis,
148+
personality: {
149+
...existing.dashboard?.analysis?.personality,
150+
cognitiveFunctionScoring: value,
151+
},
152+
},
153+
};
154+
saveConfig(existing);
155+
console.log(chalk.green(`\nCognitive function scoring set to "${value}".`));
156+
if (value === 'llm-vote') {
157+
console.log(chalk.gray(' Only applies when generating a new snapshot (Generate button / POST /generate) — requires an LLM configured via `code-insights config llm`.\n'));
158+
} else {
159+
console.log('');
160+
}
161+
trackEvent('cli_config', { subcommand: 'set', success: true });
162+
} else if (key === 'personality-vote-rounds') {
163+
const rounds = parseInt(value, 10);
164+
if (!Number.isFinite(rounds) || rounds < 1 || rounds > 7) {
165+
console.error(chalk.red(`\nInvalid value "${value}". Must be an integer between 1 and 7.\n`));
166+
process.exit(1);
167+
}
168+
const existing = loadConfig() ?? { sync: { claudeDir: '~/.claude/projects', excludeProjects: [] } };
169+
existing.dashboard = {
170+
...existing.dashboard,
171+
analysis: {
172+
...existing.dashboard?.analysis,
173+
personality: {
174+
...existing.dashboard?.analysis?.personality,
175+
llmVoteRounds: rounds,
176+
},
177+
},
178+
};
179+
saveConfig(existing);
180+
console.log(chalk.green(`\nLLM vote rounds set to ${rounds}.\n`));
181+
trackEvent('cli_config', { subcommand: 'set', success: true });
127182
} else {
128-
console.error(chalk.red(`\nUnknown config key "${key}". Available: telemetry.\n`));
183+
console.error(chalk.red(`\nUnknown config key "${key}". Available: telemetry, personality-scoring, personality-vote-rounds.\n`));
129184
process.exit(1);
130185
}
131186
});

0 commit comments

Comments
 (0)