@@ -0,0 +1,226 @@
/**
* Motion — the reusable practice that sits alongside the ledger.
*
* PIG already models what capacity was bought and what was sold. It does not
* model the *motion*: the repeatable work that turns a messy customer
* conversation into a scoped deployment, and turns that deployment into an
* asset the next one reuses. `DEMAND_STAGES` is already exactly that sequence,
* so Motion is deliberately NOT a parallel entity tree — every kind below
* declares which demand stages it serves, and the artifacts hang off deals that
* already exist.
*
* The loop the whole feature exists for:
*
* library template --instantiate--> engagement artifact --promote--> template v2
*
* A template is never edited in place once it has been used; promotion writes a
* new version row pointing back at its predecessor and at the artifact that
* proved it. That is what makes "each deployment makes the next one easier" a
* mechanism rather than a slogan, and it is why `usageCount` and the lineage
* columns are load-bearing rather than decoration.
*
* No zod in this file. `packages/core` is the ontology layer; the API route
* modules build their schemas from these constants, so removing a value stops
* validating rather than silently persisting.
*/
import { DEMAND_OPEN_STAGES , type DemandStage } from './ontology' ;
// ---------------------------------------------------------------------------
// The nine kinds
// ---------------------------------------------------------------------------
export const MOTION_KINDS = [
'discovery' ,
'qualification' ,
'poc' ,
'proposal' ,
'pricing' ,
'architecture' ,
'case_study' ,
'narrative' ,
'playbook' ,
] as const ;
export type MotionKind = ( typeof MOTION_KINDS ) [ number ] ;
export const MOTION_KIND_LABELS : Record < MotionKind , string > = {
discovery : 'Discovery' ,
qualification : 'Qualification framework' ,
poc : 'POC structure' ,
proposal : 'Proposal blocks' ,
pricing : 'Pricing and packaging' ,
architecture : 'Reference architecture' ,
case_study : 'Case study' ,
narrative : 'Technical narrative' ,
playbook : 'Deployment playbook' ,
} ;
/**
* One line each, in the register of `ontology.ts`: what the kind is *for*, not
* what it contains. These strings are shown in the library filter and in
* Piggy's tool description, so they are the only definition most people read.
*/
export const MOTION_KIND_DESCRIPTIONS : Record < MotionKind , string > = {
discovery : 'The questions that surface what a customer is actually training, before anyone scopes it.' ,
qualification : 'Weighted dimensions that turn a judgement about a deal into a score somebody can argue with.' ,
poc : 'What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever.' ,
proposal : 'Language blocks assembled into a proposal, so wording that survived procurement is reused.' ,
pricing : 'The inputs behind a quoted price: term, commitment shape, and what the block cost.' ,
architecture : 'A deployment shape that has already worked, described well enough to be copied.' ,
case_study : 'A deployment written up as evidence, for the next customer who asks whether this is real.' ,
narrative : 'The technical argument for why this capacity suits this workload, written once.' ,
playbook : 'The end-to-end sequence for a deployment, spanning every stage rather than one.' ,
} ;
/**
* Which demand stages each kind serves.
*
* The closed stages are absent everywhere on purpose: a deal that is won or
* lost has left the motion, and offering to instantiate a discovery template
* into it would be an invitation to file work against a dead deal. `playbook`
* spans the whole live motion, which is what distinguishes it from the eight
* kinds that answer one stage.
*/
export const MOTION_KIND_STAGES : Record < MotionKind , readonly DemandStage [ ] > = {
discovery : [ 'qualification' , 'scoping' ] ,
qualification : [ 'qualification' ] ,
poc : [ 'poc' ] ,
proposal : [ 'proposal' , 'procurement' ] ,
pricing : [ 'proposal' , 'procurement' ] ,
architecture : [ 'scoping' , 'poc' , 'deployment' ] ,
case_study : [ 'qualification' , 'proposal' , 'expansion' ] ,
narrative : [ 'qualification' , 'scoping' , 'proposal' ] ,
playbook : DEMAND_OPEN_STAGES ,
} ;
/**
* Private is the default, and it is a real access rule rather than a label —
* see the header of `packages/db/src/schema/motion.ts`. A library nobody can
* draft in privately becomes a library nobody drafts in.
*/
export const MOTION_VISIBILITIES = [ 'private' , 'shared' ] as const ;
export type MotionVisibility = ( typeof MOTION_VISIBILITIES ) [ number ] ;
export const MOTION_VISIBILITY_LABELS : Record < MotionVisibility , string > = {
private : 'Private' ,
shared : 'Shared' ,
} ;
/** Only a `final` artifact may be promoted — see the promotion path in the API. */
export const ARTIFACT_STATUSES = [ 'draft' , 'review' , 'final' ] as const ;
export type ArtifactStatus = ( typeof ARTIFACT_STATUSES ) [ number ] ;
export const ARTIFACT_STATUS_LABELS : Record < ArtifactStatus , string > = {
draft : 'Draft' ,
review : 'In review' ,
final : 'Final' ,
} ;
export const ENGAGEMENT_STATUSES = [ 'open' , 'won' , 'lost' , 'paused' ] as const ;
export type EngagementStatus = ( typeof ENGAGEMENT_STATUSES ) [ number ] ;
export const ENGAGEMENT_STATUS_LABELS : Record < EngagementStatus , string > = {
open : 'Open' ,
won : 'Won' ,
lost : 'Lost' ,
paused : 'Paused' ,
} ;
export function isMotionKind ( value : string ) : value is MotionKind {
return ( MOTION_KINDS as readonly string [ ] ) . includes ( value ) ;
}
export function isMotionVisibility ( value : string ) : value is MotionVisibility {
return ( MOTION_VISIBILITIES as readonly string [ ] ) . includes ( value ) ;
}
export function isArtifactStatus ( value : string ) : value is ArtifactStatus {
return ( ARTIFACT_STATUSES as readonly string [ ] ) . includes ( value ) ;
}
export function isEngagementStatus ( value : string ) : value is EngagementStatus {
return ( ENGAGEMENT_STATUSES as readonly string [ ] ) . includes ( value ) ;
}
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
/** Each dimension is scored on this scale, inclusive. */
export const MOTION_MIN_DIMENSION_SCORE = 0 ;
export const MOTION_MAX_DIMENSION_SCORE = 4 ;
/** The full scale of a motion score. Basis points, exactly as money is cents. */
export const MOTION_BASIS_POINTS_MAX = 10 _000 ;
export interface MotionDimensionScore {
readonly id : string ;
readonly weight : number ;
readonly score : number ;
}
/**
* Weighted score in basis points of the maximum (0– 10000). Weights need not
* sum to 100.
*
* Basis points rather than a float for the same reason money is cents: a score
* decides what a seller is told to do, it is persisted, and 0.55 is not 0.55 in
* binary. The whole computation stays in integers and rounds half-up at the
* single division, so the stored number is the number that was displayed.
*
* Inputs are normalised rather than rejected — a dimension carrying a
* non-finite weight is a bug upstream, and throwing here would take a
* dashboard down rather than under-weight one row.
*/
export function motionScoreBasisPoints ( dimensions : readonly MotionDimensionScore [ ] ) : number {
let weightTotal = 0 ;
let weightedScoreTotal = 0 ;
for ( const dimension of dimensions ) {
if ( ! Number . isFinite ( dimension . weight ) || ! Number . isFinite ( dimension . score ) ) continue ;
const weight = Math . round ( dimension . weight ) ;
if ( weight <= 0 ) continue ;
const score = Math . min (
MOTION_MAX_DIMENSION_SCORE ,
Math . max ( MOTION_MIN_DIMENSION_SCORE , Math . round ( dimension . score ) ) ,
) ;
weightTotal += weight ;
weightedScoreTotal += weight * score ;
}
// No weight is not a zero score dressed up — it is "nothing was asked". Both
// answer 0 here because a qualification with no dimensions has to render as
// something, and NaN renders as `NaN`.
if ( weightTotal === 0 ) return 0 ;
const numerator = MOTION_BASIS_POINTS_MAX * weightedScoreTotal ;
const denominator = MOTION_MAX_DIMENSION_SCORE * weightTotal ;
// Half-up, in integers. `Math.round` on the quotient would be a float
// division first, which is exactly the step that loses the half.
return Math . floor ( ( 2 * numerator + denominator ) / ( 2 * denominator ) ) ;
}
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' } ,
] 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.
*
* 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.
*/
export function motionBand ( basisPoints : number ) : MotionBand {
const clamped = Math . min (
MOTION_BASIS_POINTS_MAX ,
Math . max ( 0 , Number . isFinite ( basisPoints ) ? Math . round ( basisPoints ) : 0 ) ,
) ;
return MOTION_BANDS . find ( ( band ) = > clamped >= band . min && clamped <= band . max ) ? ? MOTION_BANDS [ 0 ] ;
}