mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-20 11:19:56 +08:00
feat: psychographic signal map + builder archetypes
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) <noreply@anthropic.com>
This commit is contained in:
186
scripts/archetypes.ts
Normal file
186
scripts/archetypes.ts
Normal file
@@ -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<Dimension, number>;
|
||||||
|
/** 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<Dimension, number>, b: Record<Dimension, number>): 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<Dimension, number>): 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);
|
||||||
|
}
|
||||||
272
scripts/psychographic-signals.ts
Normal file
272
scripts/psychographic-signals.ts
Normal file
@@ -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<string, Record<string, DimensionDelta[]>> = {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 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<Dimension, number>,
|
||||||
|
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<string>();
|
||||||
|
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<Dimension, number> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -27,6 +27,20 @@ import {
|
|||||||
DESTRUCTIVE_PATTERN_LIST,
|
DESTRUCTIVE_PATTERN_LIST,
|
||||||
ONE_WAY_SKILL_CATEGORY_SET,
|
ONE_WAY_SKILL_CATEGORY_SET,
|
||||||
} from '../scripts/one-way-doors';
|
} 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 fs from 'fs';
|
||||||
import * as path from 'path';
|
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', () => {
|
test('signal_keys in registry are typed strings', () => {
|
||||||
for (const q of Object.values(QUESTIONS as Record<string, QuestionDef>)) {
|
for (const q of Object.values(QUESTIONS as Record<string, QuestionDef>)) {
|
||||||
if (q.signal_key !== undefined) {
|
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
|
test('every signal_key in registry has a SIGNAL_MAP entry', () => {
|
||||||
// signal_key referenced in QUESTIONS has a matching entry in the signal map.
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user