From 5e20a3b718d699acae7c5d3ef0e82f4499fc6c2c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 17 Apr 2026 06:14:55 +0800 Subject: [PATCH] feat: psychographic signal map + builder archetypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/psychographic-signals.ts — hand-crafted {signal_key, user_choice} → {dimension, delta} map. Version 0.1.0. Conservative deltas (±0.03 to ±0.06 per event). Covers 9 signal keys: scope-appetite, architecture-care, code-quality-care, test-discipline, detail-preference, design-care, devex-care, distribution-care, session-mode. Helpers: applySignal() mutates running totals, newDimensionTotals() creates empty starting state, normalizeToDimensionValue() sigmoid-clamps accumulated delta to [0,1] (0 → 0.5 neutral), validateRegistrySignalKeys() checks that every signal_key in the registry has a SIGNAL_MAP entry. In v1 the signal map is used ONLY to compute inferred dimension values for /plan-tune inspection output. No skill behavior adapts to these signals until v2. scripts/archetypes.ts — 8 named archetypes + Polymath fallback: - Cathedral Builder (boil-the-ocean + architecture-first) - Ship-It Pragmatist (small scope + fast) - Deep Craft (detail-verbose + principled) - Taste Maker (intuitive, overrides recommendations) - Solo Operator (high-autonomy, delegates) - Consultant (hands-on, consulted on everything) - Wedge Hunter (narrow scope aggressively) - Builder-Coach (balanced steering) - Polymath (fallback when no archetype matches) matchArchetype() uses L2 distance scaled by tightness, with a 0.55 threshold below which we return Polymath. v1 ships the model stable; v2 narrative/vibe commands wire it into user-facing output. 14 new tests: signal map consistency vs registry, applySignal behavior for known/unknown keys, normalization bounds, archetype schema validity, name uniqueness, matchArchetype correctness for each reference profile, Polymath fallback for outliers. 41 pass, 0 fail total in test/plan-tune.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/archetypes.ts | 186 +++++++++++++++++++++ scripts/psychographic-signals.ts | 272 +++++++++++++++++++++++++++++++ test/plan-tune.test.ts | 135 ++++++++++++++- 3 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 scripts/archetypes.ts create mode 100644 scripts/psychographic-signals.ts diff --git a/scripts/archetypes.ts b/scripts/archetypes.ts new file mode 100644 index 00000000..3be17835 --- /dev/null +++ b/scripts/archetypes.ts @@ -0,0 +1,186 @@ +/** + * Archetypes — one-word builder identities computed from dimension clusters. + * + * Used by future /plan-tune vibe and /plan-tune narrative commands (v2). + * v1 ships the definitions but doesn't wire them into user-facing output + * yet. This file exists so the archetype model is stable by the time v2 + * narrative generation ships. + * + * Design + * ------ + * Each archetype is a point or region in the 5-dimensional psychographic + * space. `distance()` computes L2 distance from a profile to the archetype + * center, scaled by the archetype's "tightness" (how close you have to be + * to match). The archetype with smallest distance is the user's match. + * + * When no archetype is within threshold, return 'Polymath' — a calibrated + * "doesn't fit the common patterns" label that's respectful rather than + * generic. + */ + +import type { Dimension } from './psychographic-signals'; + +export interface Archetype { + /** Short vibe label — one or two words. */ + name: string; + /** One-line description anchored in observable behavior. */ + description: string; + /** Center point in the 5-dimensional space. */ + center: Record; + /** Inverse-weighted radius. Smaller = tighter match needed. */ + tightness: number; +} + +export const ARCHETYPES: readonly Archetype[] = [ + { + name: 'Cathedral Builder', + description: 'Boil the ocean. Architecture first. Ship the complete thing.', + center: { + scope_appetite: 0.85, + risk_tolerance: 0.55, + detail_preference: 0.5, + autonomy: 0.5, + architecture_care: 0.85, + }, + tightness: 1.0, + }, + { + name: 'Ship-It Pragmatist', + description: 'Small scope, fast iteration. Good enough is done.', + center: { + scope_appetite: 0.25, + risk_tolerance: 0.75, + detail_preference: 0.3, + autonomy: 0.65, + architecture_care: 0.4, + }, + tightness: 1.0, + }, + { + name: 'Deep Craft', + description: 'Every detail matters. Verbose explanations. Slow and considered.', + center: { + scope_appetite: 0.6, + risk_tolerance: 0.35, + detail_preference: 0.85, + autonomy: 0.35, + architecture_care: 0.85, + }, + tightness: 1.0, + }, + { + name: 'Taste Maker', + description: 'Decisions feel intuitive. Overrides recommendations when taste dictates.', + center: { + scope_appetite: 0.6, + risk_tolerance: 0.6, + detail_preference: 0.5, + autonomy: 0.4, + architecture_care: 0.7, + }, + tightness: 0.9, + }, + { + name: 'Solo Operator', + description: 'High autonomy. Delegate to the agent. Trust but verify.', + center: { + scope_appetite: 0.5, + risk_tolerance: 0.7, + detail_preference: 0.3, + autonomy: 0.85, + architecture_care: 0.55, + }, + tightness: 0.9, + }, + { + name: 'Consultant', + description: 'Hands-on. Wants to be consulted on everything. Verifies each step.', + center: { + scope_appetite: 0.5, + risk_tolerance: 0.3, + detail_preference: 0.7, + autonomy: 0.2, + architecture_care: 0.65, + }, + tightness: 0.9, + }, + { + name: 'Wedge Hunter', + description: 'Narrow scope aggressively. Find the smallest thing worth building.', + center: { + scope_appetite: 0.15, + risk_tolerance: 0.5, + detail_preference: 0.4, + autonomy: 0.55, + architecture_care: 0.6, + }, + tightness: 0.85, + }, + { + name: 'Builder-Coach', + description: 'Balanced steering. Makes room for the agent to propose and challenge.', + center: { + scope_appetite: 0.55, + risk_tolerance: 0.5, + detail_preference: 0.55, + autonomy: 0.55, + architecture_care: 0.6, + }, + tightness: 0.75, + }, +]; + +/** + * Fallback used when no archetype is close enough — meaning the user's + * dimension cluster genuinely doesn't match any named pattern. + */ +export const FALLBACK_ARCHETYPE: Archetype = { + name: 'Polymath', + description: "Your steering style doesn't fit a common archetype. That's a compliment.", + center: { scope_appetite: 0.5, risk_tolerance: 0.5, detail_preference: 0.5, autonomy: 0.5, architecture_care: 0.5 }, + tightness: 0, +}; + +const DIMENSIONS: readonly Dimension[] = [ + 'scope_appetite', + 'risk_tolerance', + 'detail_preference', + 'autonomy', + 'architecture_care', +] as const; + +function euclidean(a: Record, b: Record): number { + let sumSq = 0; + for (const d of DIMENSIONS) { + const diff = (a[d] ?? 0.5) - (b[d] ?? 0.5); + sumSq += diff * diff; + } + return Math.sqrt(sumSq); +} + +/** + * Match a profile to its best archetype. + * Returns FALLBACK_ARCHETYPE if no defined archetype is within threshold. + */ +export function matchArchetype(dims: Record): Archetype { + let best: Archetype = FALLBACK_ARCHETYPE; + let bestScore = Infinity; // lower is better + // Threshold: if no archetype scores below this, return Polymath. + // Max possible distance in [0,1]^5 is sqrt(5) ≈ 2.236. 0.55 = ~half the space. + const THRESHOLD = 0.55; + for (const arch of ARCHETYPES) { + const dist = euclidean(dims, arch.center); + // Scale by tightness — tighter archetypes require smaller actual distance. + const scaled = dist / (arch.tightness || 1); + if (scaled < bestScore && scaled <= THRESHOLD) { + bestScore = scaled; + best = arch; + } + } + return best; +} + +/** All archetype names, useful for tests and /plan-tune stats. */ +export function getAllArchetypeNames(): string[] { + return ARCHETYPES.map((a) => a.name).concat(FALLBACK_ARCHETYPE.name); +} diff --git a/scripts/psychographic-signals.ts b/scripts/psychographic-signals.ts new file mode 100644 index 00000000..bde4723b --- /dev/null +++ b/scripts/psychographic-signals.ts @@ -0,0 +1,272 @@ +/** + * Psychographic Signal Map — hand-crafted {question_id, user_choice} → {dimension, delta}. + * + * Consumed in v1 ONLY to compute inferred dimension values for /plan-tune + * inspection output. No skill behavior adapts to these signals in v1. + * + * When v2 wires 5 skills to consume the profile, this map is the source of + * truth for how behavior influences dimensions. Calibration deltas in v1 are + * best-guess starting points; v2 recalibrates from real observed data. + * + * Design principles + * ----------------- + * 1. Hand-crafted, not agent-inferred (Codex #4, user Decision C). + * Every mapping is explicit TypeScript — no runtime NL interpretation. + * + * 2. Small, conservative deltas (±0.03 to ±0.06 typical). + * A single answer should nudge the profile, not reshape it. Repeated + * answers across sessions accumulate. + * + * 3. Tied to registry signal_key. + * Each entry in this map corresponds to a signal_key declared in + * scripts/question-registry.ts. The derivation pipeline uses the + * question's signal_key + user_choice as the lookup key. + * + * 4. Not every question contributes to every dimension. + * Many questions have no signal_key — they're logged but don't move + * the psychographic. Only questions that genuinely reveal preference + * get a signal_key. + * + * Dimensions + * ---------- + * scope_appetite: 0 = small-scope, ship fast ↔ 1 = boil the ocean + * risk_tolerance: 0 = conservative, ask first ↔ 1 = move fast, auto-decide + * detail_preference: 0 = terse, just do it ↔ 1 = verbose, explain everything + * autonomy: 0 = hands-on, consult me ↔ 1 = delegate, trust the agent + * architecture_care: 0 = pragmatic, ship it ↔ 1 = principled, get it right + */ + +import { QUESTIONS } from './question-registry'; + +/** The 5 dimensions of the developer psychographic. */ +export type Dimension = + | 'scope_appetite' + | 'risk_tolerance' + | 'detail_preference' + | 'autonomy' + | 'architecture_care'; + +export const ALL_DIMENSIONS: readonly Dimension[] = [ + 'scope_appetite', + 'risk_tolerance', + 'detail_preference', + 'autonomy', + 'architecture_care', +] as const; + +/** + * Semantic version of the signal map. Increment when deltas change so that + * cached profiles can detect staleness and recompute from events. + */ +export const SIGNAL_MAP_VERSION = '0.1.0'; + +export interface DimensionDelta { + dim: Dimension; + delta: number; +} + +/** + * Signal map: signal_key → user_choice → list of dimension nudges. + * + * Indexed by signal_key (declared in question-registry entries), not + * question_id directly. This lets multiple questions share a semantic + * pattern (e.g., scope-appetite signal comes from both plan-ceo-review + * expansion proposals AND office-hours approach selection). + */ +export const SIGNAL_MAP: Record> = { + // ----------------------------------------------------------------------- + // scope-appetite — how much the user likes to expand scope + // ----------------------------------------------------------------------- + 'scope-appetite': { + // plan-ceo-review mode choice + expand: [{ dim: 'scope_appetite', delta: +0.06 }], + selective: [{ dim: 'scope_appetite', delta: +0.03 }], + hold: [{ dim: 'scope_appetite', delta: -0.01 }], + reduce: [{ dim: 'scope_appetite', delta: -0.06 }], + // plan-ceo-review expansion proposal accepted/deferred/skipped + accept: [{ dim: 'scope_appetite', delta: +0.04 }], + defer: [{ dim: 'scope_appetite', delta: -0.01 }], + skip: [{ dim: 'scope_appetite', delta: -0.03 }], + // office-hours approach choice + minimal: [{ dim: 'scope_appetite', delta: -0.04 }], + ideal: [{ dim: 'scope_appetite', delta: +0.05 }], + creative: [{ dim: 'scope_appetite', delta: +0.02 }], + }, + + // ----------------------------------------------------------------------- + // architecture-care — how much the user sweats the details + // ----------------------------------------------------------------------- + 'architecture-care': { + 'fix-now': [ + { dim: 'architecture_care', delta: +0.05 }, + { dim: 'risk_tolerance', delta: -0.02 }, + ], + defer: [{ dim: 'architecture_care', delta: -0.02 }], + 'accept-risk': [ + { dim: 'architecture_care', delta: -0.04 }, + { dim: 'risk_tolerance', delta: +0.04 }, + ], + }, + + // ----------------------------------------------------------------------- + // code-quality-care — proxies detail_preference + architecture_care + // ----------------------------------------------------------------------- + 'code-quality-care': { + 'fix-now': [ + { dim: 'detail_preference', delta: +0.02 }, + { dim: 'architecture_care', delta: +0.03 }, + ], + 'ack-and-ship': [ + { dim: 'risk_tolerance', delta: +0.03 }, + { dim: 'architecture_care', delta: -0.02 }, + ], + 'false-positive': [{ dim: 'architecture_care', delta: +0.01 }], + defer: [{ dim: 'architecture_care', delta: -0.02 }], + skip: [{ dim: 'detail_preference', delta: -0.03 }], + }, + + // ----------------------------------------------------------------------- + // test-discipline — proxies architecture_care + detail_preference + // ----------------------------------------------------------------------- + 'test-discipline': { + 'fix-now': [ + { dim: 'architecture_care', delta: +0.04 }, + { dim: 'detail_preference', delta: +0.02 }, + ], + investigate: [{ dim: 'architecture_care', delta: +0.02 }], + 'ack-and-ship': [ + { dim: 'risk_tolerance', delta: +0.04 }, + { dim: 'architecture_care', delta: -0.03 }, + ], + 'add-test': [ + { dim: 'architecture_care', delta: +0.03 }, + { dim: 'detail_preference', delta: +0.02 }, + ], + defer: [{ dim: 'architecture_care', delta: -0.01 }], + skip: [{ dim: 'architecture_care', delta: -0.04 }], + }, + + // ----------------------------------------------------------------------- + // detail-preference — direct signal for verbosity + // ----------------------------------------------------------------------- + 'detail-preference': { + accept: [{ dim: 'detail_preference', delta: +0.03 }], + skip: [{ dim: 'detail_preference', delta: -0.03 }], + }, + + // ----------------------------------------------------------------------- + // design-care — proxies architecture_care for UI-facing work + // ----------------------------------------------------------------------- + 'design-care': { + expand: [{ dim: 'architecture_care', delta: +0.04 }], + polish: [{ dim: 'architecture_care', delta: +0.02 }], + triage: [{ dim: 'architecture_care', delta: -0.02 }], + 'fix-now': [{ dim: 'architecture_care', delta: +0.02 }], + defer: [{ dim: 'architecture_care', delta: -0.01 }], + skip: [{ dim: 'architecture_care', delta: -0.03 }], + }, + + // ----------------------------------------------------------------------- + // devex-care — DX is UX for developers; proxies architecture_care + // ----------------------------------------------------------------------- + 'devex-care': { + expand: [{ dim: 'architecture_care', delta: +0.04 }], + polish: [{ dim: 'architecture_care', delta: +0.02 }], + triage: [{ dim: 'architecture_care', delta: -0.02 }], + 'fix-now': [{ dim: 'architecture_care', delta: +0.02 }], + defer: [{ dim: 'architecture_care', delta: -0.01 }], + skip: [{ dim: 'architecture_care', delta: -0.03 }], + }, + + // ----------------------------------------------------------------------- + // distribution-care — does the user care about how code reaches users? + // ----------------------------------------------------------------------- + 'distribution-care': { + accept: [{ dim: 'architecture_care', delta: +0.03 }], + defer: [{ dim: 'architecture_care', delta: -0.02 }], + skip: [{ dim: 'architecture_care', delta: -0.04 }], + }, + + // ----------------------------------------------------------------------- + // session-mode — office-hours goal selection + // ----------------------------------------------------------------------- + 'session-mode': { + startup: [ + { dim: 'scope_appetite', delta: +0.02 }, + { dim: 'architecture_care', delta: +0.02 }, + ], + intrapreneur: [{ dim: 'scope_appetite', delta: +0.02 }], + hackathon: [ + { dim: 'risk_tolerance', delta: +0.03 }, + { dim: 'architecture_care', delta: -0.02 }, + ], + 'oss-research': [{ dim: 'architecture_care', delta: +0.02 }], + learning: [{ dim: 'detail_preference', delta: +0.02 }], + fun: [{ dim: 'risk_tolerance', delta: +0.02 }], + }, +}; + +/** + * Apply a user choice for a question to the running dimension totals. + * + * @param dims - running total of dimension nudges (mutated) + * @param signal_key - from the question registry entry + * @param user_choice - the option key the user selected + * @returns list of dimension deltas applied (empty if no mapping) + */ +export function applySignal( + dims: Record, + signal_key: string, + user_choice: string, +): DimensionDelta[] { + const subMap = SIGNAL_MAP[signal_key]; + if (!subMap) return []; + const deltas = subMap[user_choice]; + if (!deltas) return []; + for (const { dim, delta } of deltas) { + dims[dim] = (dims[dim] ?? 0) + delta; + } + return deltas; +} + +/** + * Validate that every signal_key referenced in the registry has a matching + * entry in SIGNAL_MAP. Called by tests to catch drift. + */ +export function validateRegistrySignalKeys(): { + missing: string[]; + extra: string[]; +} { + const registrySignalKeys = new Set(); + for (const q of Object.values(QUESTIONS)) { + if (q.signal_key) registrySignalKeys.add(q.signal_key); + } + const mapKeys = new Set(Object.keys(SIGNAL_MAP)); + const missing: string[] = []; + const extra: string[] = []; + for (const k of registrySignalKeys) { + if (!mapKeys.has(k)) missing.push(k); + } + for (const k of mapKeys) { + if (!registrySignalKeys.has(k)) extra.push(k); + } + return { missing, extra }; +} + +/** Empty dimension totals — starting point for derivation. */ +export function newDimensionTotals(): Record { + return { + scope_appetite: 0, + risk_tolerance: 0, + detail_preference: 0, + autonomy: 0, + architecture_care: 0, + }; +} + +/** Sigmoid clamp: map accumulated delta total to [0, 1]. */ +export function normalizeToDimensionValue(total: number): number { + // Simple sigmoid: each 1.0 of accumulated delta approaches saturation. + // 0.5 is neutral. Positive deltas push toward 1, negative toward 0. + return 1 / (1 + Math.exp(-total * 3)); +} diff --git a/test/plan-tune.test.ts b/test/plan-tune.test.ts index 3a0ffb95..de0c74a8 100644 --- a/test/plan-tune.test.ts +++ b/test/plan-tune.test.ts @@ -27,6 +27,20 @@ import { DESTRUCTIVE_PATTERN_LIST, ONE_WAY_SKILL_CATEGORY_SET, } from '../scripts/one-way-doors'; +import { + SIGNAL_MAP, + applySignal, + validateRegistrySignalKeys, + newDimensionTotals, + normalizeToDimensionValue, + ALL_DIMENSIONS, +} from '../scripts/psychographic-signals'; +import { + ARCHETYPES, + FALLBACK_ARCHETYPE, + matchArchetype, + getAllArchetypeNames, +} from '../scripts/archetypes'; import * as fs from 'fs'; import * as path from 'path'; @@ -211,10 +225,10 @@ describe('registry breadth', () => { }); // ----------------------------------------------------------------------- -// Signal map consistency (created alongside registry) +// Signal map consistency // ----------------------------------------------------------------------- -describe('psychographic signal map references', () => { +describe('psychographic signal map', () => { test('signal_keys in registry are typed strings', () => { for (const q of Object.values(QUESTIONS as Record)) { if (q.signal_key !== undefined) { @@ -225,8 +239,121 @@ describe('psychographic signal map references', () => { } }); - // When scripts/psychographic-signals.ts ships, add a test that every - // signal_key referenced in QUESTIONS has a matching entry in the signal map. + test('every signal_key in registry has a SIGNAL_MAP entry', () => { + const { missing } = validateRegistrySignalKeys(); + expect(missing).toEqual([]); + }); + + test('applySignal mutates dimension totals per mapping', () => { + const dims = newDimensionTotals(); + const applied = applySignal(dims, 'scope-appetite', 'expand'); + expect(applied.length).toBeGreaterThan(0); + expect(dims.scope_appetite).toBeCloseTo(0.06, 5); + }); + + test('applySignal returns [] for unknown signal_key', () => { + const dims = newDimensionTotals(); + const applied = applySignal(dims, 'no-such-signal', 'anything'); + expect(applied).toEqual([]); + expect(dims.scope_appetite).toBe(0); + }); + + test('applySignal returns [] for unknown user_choice', () => { + const dims = newDimensionTotals(); + const applied = applySignal(dims, 'scope-appetite', 'definitely-not-a-real-choice'); + expect(applied).toEqual([]); + }); + + test('normalizeToDimensionValue maps 0 → 0.5 (neutral)', () => { + expect(normalizeToDimensionValue(0)).toBeCloseTo(0.5, 5); + }); + + test('normalizeToDimensionValue returns values in [0, 1]', () => { + for (const total of [-10, -1, -0.5, 0, 0.5, 1, 10]) { + const v = normalizeToDimensionValue(total); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + test('ALL_DIMENSIONS has 5 entries', () => { + expect(ALL_DIMENSIONS.length).toBe(5); + }); + + test('no extra SIGNAL_MAP keys without registry reference (informational)', () => { + // Extra keys are allowed (a signal might be reserved for upcoming registry + // entries). But list them so drift is visible. + const { extra } = validateRegistrySignalKeys(); + // Allow up to 3 "reserved" extras before flagging. Tighten later. + expect(extra.length).toBeLessThanOrEqual(3); + }); +}); + +// ----------------------------------------------------------------------- +// Archetypes +// ----------------------------------------------------------------------- + +describe('archetypes', () => { + test('each archetype has name, description, center, tightness', () => { + for (const arch of ARCHETYPES) { + expect(arch.name).toBeDefined(); + expect(arch.description).toBeDefined(); + expect(arch.center).toBeDefined(); + expect(arch.tightness).toBeGreaterThan(0); + for (const d of ALL_DIMENSIONS) { + expect(typeof arch.center[d]).toBe('number'); + expect(arch.center[d]).toBeGreaterThanOrEqual(0); + expect(arch.center[d]).toBeLessThanOrEqual(1); + } + } + }); + + test('archetype names are unique', () => { + const names = ARCHETYPES.map((a) => a.name); + expect(new Set(names).size).toBe(names.length); + }); + + test('matchArchetype returns Cathedral Builder for boil-the-ocean profile', () => { + const dims = { + scope_appetite: 0.88, + risk_tolerance: 0.55, + detail_preference: 0.5, + autonomy: 0.5, + architecture_care: 0.85, + }; + const match = matchArchetype(dims); + expect(match.name).toBe('Cathedral Builder'); + }); + + test('matchArchetype returns Ship-It Pragmatist for small-scope/fast profile', () => { + const dims = { + scope_appetite: 0.22, + risk_tolerance: 0.78, + detail_preference: 0.25, + autonomy: 0.7, + architecture_care: 0.38, + }; + const match = matchArchetype(dims); + expect(match.name).toBe('Ship-It Pragmatist'); + }); + + test('matchArchetype returns Polymath for extreme-outlier profile', () => { + const dims = { + scope_appetite: 0.05, + risk_tolerance: 0.95, + detail_preference: 0.95, + autonomy: 0.05, + architecture_care: 0.05, + }; + const match = matchArchetype(dims); + expect(match.name).toBe(FALLBACK_ARCHETYPE.name); + }); + + test('getAllArchetypeNames includes Polymath fallback', () => { + const names = getAllArchetypeNames(); + expect(names).toContain('Polymath'); + expect(names.length).toBe(ARCHETYPES.length + 1); + }); }); // -----------------------------------------------------------------------