Speak the scorecard's own band labels, not a second vocabulary

The Trainability and Deal Qualification Scorecard publishes five bands and an
action for each — Decline, Defer, Scope down, Qualified conditional, Build —
and `MOTION_BANDS` published four different ones with different edges. So a
reader could read the scorecard, score a deal against the exact dimensions it
defines, and be told "Strategic" by a band table that document has never heard
of. Two answers to the same question from the same product.

The scorecard wins, on two grounds. Its edges were chosen alongside the
dimension weights they sit on top of, so 78 means something there and 7500 was
a round number here. And every one of its labels is a verb the reader can act
on: "Qualified" describes a deal, "Scope down" says what to do about it, which
is the only reason to band a score rather than show it.

The labels are now duplicated between the JSON a customer reads and the table
the product renders, because a rendered label cannot reach into a seeded row.
That duplication gets a test asserting the whole table verbatim, so
re-authoring one copy alone fails rather than drifts.

`apps/api/test/motion.test.ts` asserted the literal 'Strategic'. It now derives
the band through the shared function, so a band-table change is caught by the
test that owns the decision instead of by a write-path test that does not.
This commit is contained in:
2026-08-19 00:26:21 -07:00
parent 2f32186d22
commit b7d1ffd2d8
3 changed files with 57 additions and 18 deletions
+5 -2
View File
@@ -27,7 +27,7 @@ import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { motionScoreBasisPoints } from '@pig/core';
import { motionBand, motionScoreBasisPoints } from '@pig/core';
import type { Database, MotionTemplate } from '@pig/db';
import { teamMemberships, users } from '@pig/db';
import { Hono } from 'hono';
@@ -783,7 +783,10 @@ describe('qualification scores', () => {
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
assert.equal(written?.band, 'Strategic');
// Derived from the score by the shared function, never asserted as a
// literal here: a band table edited in @pig/core would otherwise be caught
// by this test rather than by the one that owns the decision.
assert.equal(written?.band, motionBand(motionScoreBasisPoints(dimensions)).label);
assert.equal(typeof written?.basisPoints, 'number');
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
});
+22 -7
View File
@@ -200,19 +200,34 @@ export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore
return Math.floor((2 * numerator + denominator) / (2 * denominator));
}
/*
* The band labels are the SCORECARD'S OWN, not a second vocabulary invented
* here. `seed/motion/trainability-qualification.json` publishes five bands and
* an action for each, and a reader who has just read that document and then
* sees the score rendered as "Strategic" has been told two different things
* about the same number by the same product. Every label below is a verb the
* reader can act on, which is the point of a band at all: "Qualified" describes
* the deal, "Scope down" says what to do about it.
*
* Boundaries are the authored percentages in basis points. If the scorecard's
* bands are ever re-authored, these move with them they are one decision
* recorded twice, and the JSON is the copy a customer reads.
*/
export const MOTION_BANDS = [
{ min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' },
{ min: 3500, max: 5499, label: 'Not yet', tone: 'warning' },
{ min: 5500, max: 7499, label: 'Qualified', tone: 'info' },
{ min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' },
{ min: 0, max: 2999, label: 'Decline', tone: 'danger' },
{ min: 3000, max: 4599, label: 'Defer', tone: 'danger' },
{ min: 4600, max: 6199, label: 'Scope down', tone: 'warning' },
{ min: 6200, max: 7799, label: 'Qualified, conditional', tone: 'info' },
{ min: 7800, max: 10_000, label: 'Build', tone: 'positive' },
] as const;
export type MotionBand = (typeof MOTION_BANDS)[number];
export type MotionBandTone = MotionBand['tone'];
/**
* The band a score falls in. Boundaries are inclusive at `min`, so 3500 is
* "Not yet" and 3499 is not an off-by-one here changes what a seller is told
* to do without changing any number they can see.
* The band a score falls in. Boundaries are inclusive at `min`, so 6200 is
* "Qualified, conditional" and 6199 is "Scope down" an off-by-one here
* changes what a seller is told to do without changing any number they can
* see.
*
* Out-of-range input clamps to an end band rather than returning undefined,
* because every caller renders this and none of them has a null branch.
+30 -9
View File
@@ -34,7 +34,7 @@ describe('motionScoreBasisPoints', () => {
it('returns 0 rather than throwing when every weight is zero', () => {
// The naive version divides by the weight total and produces Infinity,
// which `motionBand` would then clamp to "Strategic" — a deal nobody
// which `motionBand` would then clamp to "Build" — a deal nobody
// scored recommended as the best one in the book.
const score = motionScoreBasisPoints([
{ id: 'fit', weight: 0, score: 4 },
@@ -112,20 +112,41 @@ describe('motionBand', () => {
* file and tells a seller to defer a deal that qualified.
*/
it('is inclusive at the lower edge of every band', () => {
assert.equal(motionBand(3500).label, 'Not yet');
assert.equal(motionBand(5500).label, 'Qualified');
assert.equal(motionBand(7500).label, 'Strategic');
assert.equal(motionBand(3000).label, 'Defer');
assert.equal(motionBand(4600).label, 'Scope down');
assert.equal(motionBand(6200).label, 'Qualified, conditional');
assert.equal(motionBand(7800).label, 'Build');
});
it('keeps the basis point below each edge in the band underneath', () => {
assert.equal(motionBand(3499).label, 'Decline or defer');
assert.equal(motionBand(5499).label, 'Not yet');
assert.equal(motionBand(7499).label, 'Qualified');
assert.equal(motionBand(2999).label, 'Decline');
assert.equal(motionBand(4599).label, 'Defer');
assert.equal(motionBand(6199).label, 'Scope down');
assert.equal(motionBand(7799).label, 'Qualified, conditional');
});
it('covers both ends of the scale', () => {
assert.equal(motionBand(0).label, 'Decline or defer');
assert.equal(motionBand(10_000).label, 'Strategic');
assert.equal(motionBand(0).label, 'Decline');
assert.equal(motionBand(10_000).label, 'Build');
});
/**
* The scorecard a customer reads publishes these five bands and an action for
* each. They are duplicated in `MOTION_BANDS` because the product renders a
* label the JSON cannot reach so this pins the two copies together, and
* fails when somebody re-authors one of them alone.
*/
it('uses the labels and edges the seeded scorecard publishes', () => {
assert.deepEqual(
MOTION_BANDS.map((band) => [band.min, band.max, band.label]),
[
[0, 2999, 'Decline'],
[3000, 4599, 'Defer'],
[4600, 6199, 'Scope down'],
[6200, 7799, 'Qualified, conditional'],
[7800, 10_000, 'Build'],
],
);
});
it('leaves no gap and no overlap across the whole range', () => {