Files
portfolio-advisor/src/index.ts
T
2026-09-28 14:49:02 -04:00

215 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Effort, number> = {
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<CapabilityStanding, 'score'> {
/** 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 }