/** * PortfolioAdvisor — rank capabilities by transition readiness, and say what * would move each one. * * The failure this is built against is not a bad ranking. It is a ranking * that reads as authoritative when the numbers behind it are not comparable: * * 1. **Unassessed capabilities sort as bad.** A capability nobody scored * lands at the bottom next to one that was scored and found weak. Those * are opposite situations — one needs work, the other needs an * assessment — and a single ordered list cannot hold both. Unassessed * items are therefore ranked SEPARATELY and never interleaved. * * 2. **A composite hides its own binding constraint.** "Reports: 6" tells you * where it sits, not what to do. A ranking that cannot name the one * thing holding each capability down is a leaderboard, not advice. * * 3. **Effort is invisible in a score.** The capability at 45 that needs a * key rotation and the one at 45 that needs a rewrite are not equally * worth doing next. Ordering by score alone systematically recommends * the expensive one. * * Designed to consume `@extant2000/sustain-score` output, but takes a plain * shape so anything can feed it. * * Pure: no clock, no I/O. */ import { type CapabilityReport, type Finding, type FormatOptions, evidence, formatReport as renderReport, } from '@extant2000/evidence-record' /** How much work the binding constraint represents. */ export type Effort = 'trivial' | 'small' | 'medium' | 'large' | 'unknown' const EFFORT_WEIGHT: Record = { trivial: 1, small: 2, medium: 4, large: 8, // Unknown effort is weighted as medium rather than as trivial: an // unestimated task is not a cheap one, and defaulting optimistically is // how an unscoped item reaches the top of a plan. unknown: 4, } export interface CapabilityStanding { name: string /** * Readiness 0–100, or `null` when the capability was never assessed. * Null is NOT zero — see `rank()`. */ score: number | null /** The single thing holding it down. */ bindingConstraint?: string /** The highest-leverage next action. */ nextAction?: string effort?: Effort /** Strategic weight: how much this capability matters to the portfolio. */ weight?: number /** Where the score came from — a scorecard, a report, a run. */ where?: string } export interface Ranked extends Omit { /** Never null: an unassessed capability is not ranked at all. */ score: number /** Readiness gain per unit of effort. Higher is a better next move. */ leverage: number rank: number } export interface Ranking { /** Assessed capabilities, best next move first. */ ranked: Ranked[] /** Never assessed. Listed separately, never interleaved. */ unassessed: CapabilityStanding[] } /** * Rank by leverage: how much readiness a unit of effort buys. * * `(100 - score) * weight / effort` — the headroom that remains, scaled by * how much the capability matters, divided by what it costs. A capability * already at 90 has little headroom left however cheap the work is, and a * large task on a low-weight capability sinks even with lots of headroom. * * Unassessed capabilities are NOT ranked. They have no score to compute * headroom from, and slotting them in at zero would put "we never looked at * this" above "we looked and it is genuinely weak" — which is exactly * backwards as advice. */ export function rank(standings: CapabilityStanding[]): Ranking { const list = standings ?? [] const unassessed = list.filter(c => c.score === null) const assessed = list.filter((c): c is CapabilityStanding & { score: number } => c.score !== null) const ranked = assessed .map(c => { const effort = EFFORT_WEIGHT[c.effort ?? 'unknown'] const weight = c.weight ?? 1 return { ...c, leverage: Math.round(((100 - c.score) * weight / effort) * 10) / 10 } }) .sort((a, b) => b.leverage - a.leverage || a.score - b.score || a.name.localeCompare(b.name)) .map((c, i) => ({ ...c, rank: i + 1 })) return { ranked, unassessed } } function standingEvidence(c: CapabilityStanding) { return c.where ? evidence.measurement(`${c.name} readiness`, c.score, c.score === null ? 0 : 1, { note: c.where }) : evidence.measurement(`${c.name} readiness`, c.score, c.score === null ? 0 : 1) } /** * Build a report from a ranking. * * Every entry carries its binding constraint, because a ranking that cannot * say what is holding each capability down is a leaderboard rather than * advice. An entry with no constraint recorded says so rather than appearing * complete. */ export function toReport(standings: CapabilityStanding[]): CapabilityReport { const list = standings ?? [] if (list.length === 0) { return { capability: 'PortfolioAdvisor', scope: 'no capabilities supplied', examined: 0, findings: [], notes: ['Nothing was supplied to rank.'], } } const { ranked, unassessed } = rank(list) const rankedFindings: Finding[] = ranked.map(c => ({ id: c.name, summary: `#${c.rank} ${c.name} — ${c.score}/100, leverage ${c.leverage}${c.effort ? ` (${c.effort} effort)` : ''}`, // A ranking entry is advice, not a verdict on the capability. Only a // genuinely finished capability passes; everything else is work // outstanding, which is a `fail` against the readiness criterion. determination: c.score >= 90 ? 'pass' : 'fail', severity: c.score < 40 ? 'critical' : c.score < 70 ? 'high' : 'info', detail: c.bindingConstraint ? `Binding constraint: ${c.bindingConstraint}${c.nextAction ? ` → ${c.nextAction}` : ''}` : 'No binding constraint recorded. A rank without one is a position, not advice — it does not say what to do.', evidence: [standingEvidence(c)], })) const unassessedFindings: Finding[] = unassessed.map(c => ({ id: c.name, summary: `${c.name} has never been assessed`, determination: 'not-assessed', severity: 'high', detail: 'Not ranked. An unassessed capability has no headroom to compute, and placing it at zero would rank "nobody looked" above "we looked and it is weak" — backwards, as advice.', evidence: [], })) return { capability: 'PortfolioAdvisor', scope: `${list.length} capabilit${list.length === 1 ? 'y' : 'ies'}`, examined: list.length, findings: [...unassessedFindings, ...rankedFindings], notes: [ `${ranked.length} ranked by leverage (headroom x weight / effort), ${unassessed.length} unassessed and excluded from the ranking`, ranked.length > 0 ? `best next move: ${ranked[0]!.name} — ${ranked[0]!.nextAction ?? 'no action recorded'}` : 'no capability could be ranked', ], } } /** * The ranking itself, in rank order. * * Printed separately from the findings because the shared renderer sorts * worst-first, which is right for findings and wrong for a ranked list — it * would show #2 above #1 whenever #2 scored lower. A ranking whose display * order contradicts its own numbering is worse than no numbering. */ export function rankingTable(standings: CapabilityStanding[]): string { const { ranked, unassessed } = rank(standings ?? []) const out: string[] = [] for (const c of ranked) { const action = c.nextAction ?? c.bindingConstraint ?? 'no action recorded' out.push(` #${String(c.rank).padEnd(2)} ${c.name.padEnd(16)} ${String(c.score).padStart(3)}/100 leverage ${String(c.leverage).padStart(5)} ${action}`) } for (const c of unassessed) { out.push(` -- ${c.name.padEnd(16)} —/100 ${'unassessed'.padStart(14)} no score to rank from`) } return out.join('\n') } /** * Render a portfolio ranking for a terminal or a briefing. * * The rank-ordered table first, then the findings worst-first. They answer * different questions — "what should I do next" and "what is most wrong" — * and they are not the same order. */ export function formatRanking(standings: CapabilityStanding[], opts: FormatOptions = {}): string { const table = rankingTable(standings) const report = renderReport(toReport(standings), opts) return table ? `${table}\n\n${report}` : report } export { EFFORT_WEIGHT }