# PortfolioAdvisor Rank a portfolio of capabilities by transition readiness, and say what would move each one. PortfolioAdvisor takes a list of scored capabilities and returns them ordered by the best next move. It is designed to consume `@extant2000/sustain-score` output, but it takes a plain shape, so any source of scores can feed it. It is pure, with no clock and no I/O. ## What it is built against A bad ranking is not the main risk. The risk is a ranking that reads as authoritative when the numbers behind it are not comparable. There are three ways that happens. 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. A single ordered list cannot hold both, so unassessed capabilities are listed separately and never interleaved. A `null` score is not zero. 2. A composite hides its binding constraint. "Reports: 6" tells you where something sits, not what to do. Every entry carries its binding constraint and next action. An entry with neither is reported as "a position, not advice". 3. Effort is invisible in a score. Two capabilities at 45, one needing a key rotation and one needing a rewrite, are not equally worth doing next. So the ranking is by readiness gained per unit of effort, not by score. Each entry gets a `leverage` value: ``` (100 - score) × weight ÷ effort ``` That is the headroom left, scaled by how much the capability matters, divided by what the work costs. Effort weights are `trivial` 1, `small` 2, `medium` 4 and `large` 8. `unknown` counts as `medium`, not `trivial`. An unestimated task is not a cheap one, and defaulting optimistically is how an unscoped item reaches the top of a plan. Only a capability scoring 90 or more passes; the rest are reported as work outstanding. ## Two orders `formatRanking()` prints the ranked table first and then the findings worst-first. The two answer different questions ("what should I do next" and "what is most wrong"), and they are not the same order. A ranking whose display order contradicts its own numbering is worse than no numbering, so the table always prints #1 first. ## Usage ```ts import { rank, rankingTable, formatRanking, type CapabilityStanding } from '@extant2000/portfolio-advisor' const portfolio: CapabilityStanding[] = [ { name: 'docs', score: 90, effort: 'small', bindingConstraint: 'none material' }, { name: 'gateway', score: 85, effort: 'small', bindingConstraint: 'retry fix unmerged', nextAction: 'merge the retry fix' }, { name: 'reports', score: 60, effort: 'large', bindingConstraint: 'scheduled exports promised, nothing runs them', nextAction: 'build the scheduler' }, { name: 'billing', score: 80, effort: 'trivial', bindingConstraint: 'debug logging left on', nextAction: 'turn off debug logging' }, ] const { ranked, unassessed } = rank(portfolio) console.log(rankingTable(portfolio)) // Table plus the evidence-record findings console.log(formatRanking(portfolio)) ``` `toReport(portfolio)` returns a `CapabilityReport` from `@extant2000/evidence-record`. Each entry can also carry a `weight` (default 1) and a `where` that is cited as the source of the score. ## Sample output Produced by running `rankingTable()` on the example above: ``` #1 billing 80/100 leverage 20 turn off debug logging #2 gateway 85/100 leverage 7.5 merge the retry fix #3 reports 60/100 leverage 5 build the scheduler #4 docs 90/100 leverage 5 none material ``` The weakest capability is third. Ordering by score alone would have put the large rebuild ahead of a change that is close to free. A capability with `score: null` would appear below the table, unnumbered, as unassessed. ## Install The package is published to the GitLab package registry, so point the `@extant2000` scope there first: ``` npm config set @extant2000:registry https://gitlab.com/api/v4/packages/npm/ npm install @extant2000/portfolio-advisor ``` ## License MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE).