Exact arithmetic for a six-factor viability score, in Ruby with no dependencies.
Six subscores from 0 to 100, six weights that sum to 1.00, one weighted total, and
one of three verdicts: GO, CONDITIONAL or KILL. That is the whole contract.
The reason it is a library and not a one-line multiply is that the total has to be
exact, because a threshold is the only thing anybody acts on and floating point
disagrees with itself near a threshold.
require "niche_viability"
score = NicheViability.score(a: 40, v: 49, s: 78, c: 37, m: 71, b: 95)
score.total # => (2797/50), exactly 55.94
score.reported # => 55.9
score.verdict # => :go
score.page_cap # => nil, a GO is not capped
score.contribution(:s) # => (312/25), exactly 12.48$ niche-viability --a 45 --v 45 --s 45 --c 45 --m 45 --b 45
factor sub wt pts
A Difficulty accessibility 45.0 0.26 11.70
V Addressable volume 45.0 0.24 10.80
S SERP capture headroom 45.0 0.16 7.20
C Competitor displaceability 45.0 0.14 6.30
M Monetization fit 45.0 0.12 5.40
B Format buildability 45.0 0.08 3.60
total, exact 45.00
score 45.0
verdict CONDITIONAL
cap 25 pages
$ echo $?
1
gem install niche_viability
Or from a checkout: rake test runs the suite, ruby -Ilib exe/niche-viability --help
runs the CLI. Ruby 2.6 or newer, standard library only.
| key | factor | weight |
|---|---|---|
a |
difficulty accessibility | 0.26 |
v |
addressable volume | 0.24 |
s |
SERP capture headroom | 0.16 |
c |
competitor displaceability | 0.14 |
m |
monetization fit | 0.12 |
b |
format buildability | 0.08 |
VIABILITY = 0.26a + 0.24v + 0.16s + 0.14c + 0.12m + 0.08b, and the verdict is
KILL below 40, CONDITIONAL below 55, GO otherwise. A CONDITIONAL verdict
carries a 25-page cap, which Score#page_cap returns so a caller can enforce it.
None of the numbers above were invented here. The weight vector, both reference
profiles and the two thresholds are taken from the published specification of the
score, the niche score calculator and its printed
method, which prints the formula,
the six weights with a written justification for each, and the derivation of the
thresholds from the two reference profiles. If you want to argue with a weight, that
page is where the argument is. For where this one score sits among the stages that
surround it — it is the gate at the end of stage 02, and the stages after it never
start if it returns KILL — see the six-stage
method.
This library implements only the arithmetic. It does not sample a SERP, estimate a
subscore, or know anything about your niche. Six numbers you supply yourself cannot
tell you what a search engine will do, and a GO here is a decision about your own
calendar rather than a forecast of traffic, rankings or indexation.
The two reference profiles ship as fixtures and as a public API, because a threshold changed without re-deriving the profile it came from is a configuration error rather than a preference:
NicheViability.reference(:k).reported # => 39.1, immediately below the kill line
NicheViability.reference(:g).reported # => 56.0, immediately above the conditional lineWeights are stored as integer hundredths and subscores as exact Rationals parsed
without ever passing through a binary float. Three things follow, and each one is a
test in the suite:
- The total is the sum of the unrounded contributions. Profile G's six
contributions round to
10.48 + 11.76 + 12.45 + 5.17 + 8.56 + 7.58 = 56.00, but the exact total is55.992, which reports as56.0. Round first and you get a different number. Near 55 that is the difference between aGOand aCONDITIONAL. - No drift.
0.26 * 40.3in Ruby floats is10.478000000000002. Here it isRational(10478, 1000), so two implementations of the same contract agree to the last digit rather than to within an epsilon. - Thresholds are evaluated on the exact total, not the reported one. A score of
39.96reports as40.0and is still aKILL.
A check that cannot be evaluated is a failure, not a pass, so the library raises rather than guessing:
- a subscore outside
0..100, missing, non-numeric, or carrying more than one decimal place — the contract carries one, and silently rounding a second one away would move a score across a threshold without saying so; - an unknown subscore key, which is nearly always a typo for a real one;
- a weight vector that does not sum to exactly
1.00, or one holding a non-integer number of hundredths. This isWeightSumErrorrather than a warning because a vector summing to 0.98 still returns a plausible-looking number, on a different scale, that no longer means what the thresholds mean.
Weights are overridable as long as they still sum to one:
weights = NicheViability::Weights.new(a: 30, v: 20, s: 16, c: 14, m: 12, b: 8)
NicheViability::Score.new({ a: 40, v: 49, s: 78, c: 37, m: 71, b: 95 }, weights: weights)niche-viability takes the six subscores as flags, or a JSON object on stdin, and
prints either the contribution table or JSON (--json). --reference k and
--reference g score the two published profiles. Exit status is the verdict, so the
tool composes into a shell script or a CI job:
| status | meaning |
|---|---|
| 0 | GO |
| 1 | CONDITIONAL |
| 2 | KILL |
| 64 | bad input; the reason goes to stderr |
rake test — 37 tests, 1097 assertions, no dependencies beyond minitest from the
standard library. They cover both reference profiles against their published
per-factor arithmetic, both thresholds at the exact boundary, 1000 pseudo-random
profiles checked against the definition of the formula, every refusal listed above,
and the CLI's output and exit statuses.
MIT. See LICENSE.