From b7d1ffd2d8a2e7b540cffc78ca8e137361c11e58 Mon Sep 17 00:00:00 2001 From: Kartios Date: Wed, 19 Aug 2026 00:26:21 -0700 Subject: [PATCH] Speak the scorecard's own band labels, not a second vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/test/motion.test.ts | 7 ++++-- packages/core/src/motion.ts | 29 +++++++++++++++++------ packages/core/test/motion.test.ts | 39 ++++++++++++++++++++++++------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/apps/api/test/motion.test.ts b/apps/api/test/motion.test.ts index c35963c..97b83ba 100644 --- a/apps/api/test/motion.test.ts +++ b/apps/api/test/motion.test.ts @@ -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'); }); diff --git a/packages/core/src/motion.ts b/packages/core/src/motion.ts index 2ae9816..562ce66 100644 --- a/packages/core/src/motion.ts +++ b/packages/core/src/motion.ts @@ -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. diff --git a/packages/core/test/motion.test.ts b/packages/core/test/motion.test.ts index 4d0f665..3931263 100644 --- a/packages/core/test/motion.test.ts +++ b/packages/core/test/motion.test.ts @@ -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', () => {