Close #29: the v1 rate structure types

Price is a rate function, not a scalar (#22), and #15 bars any model from
evaluating one — so the shapes have to be closed AND computable. This replaces
the src/pricing seam with a composition rather than an enumeration: one base
schedule deciding unit price, plus closed modifiers, plus an optional floor.
Three vendors on one small RFP already produced five cells of the alternative
cross-product, and two of the three are the same object wearing different
decoration.

The scenario vocabulary is closed too, and published with the RFP, so every bid
in a field is priced against identical assumptions. A structure reading a
variable the RFP never declared is rejected at submission, while the vendor can
still fix it.

Abstention is typed. `input_missing` is separated from `shape_unsupported`
because Chesapeake's shape is fully supported and its unit price simply is not
in the document — that is recoverable by asking, and collapsing the two throws
away the only actionable fact. Null is never zero, never the vendor's stated
figure, and never a partial sum.

The derivation is the record. A bare scalar can only assert; a protest asks why
a bid ranked where it did. Lines accumulate as exact rationals in bigint and
round once, half-up. A scenario amendment voids derived costs rather than
recomputing them.

Exceptions carry a closed consequence union. Anacostia's EX-3 says the vendor
cannot bid produce at all if declined — a nullable price delta reads that as "no
cost impact", the inverse of the truth, on the most consequential of the three.

Two defects the corpus check caught that review would not have:

  - The band ladder priced two ways depending on a flag. `to` is inclusive, and
    subtracting those bounds in the marginal path lost the unit between bands —
    one in 21,400, invisible in the total and wrong in the record.

  - The published scenario was quantizing the price before the evaluator saw it.
    Twelve basis-point shares moved the seasonal factor by 3e-5, about $19 on a
    $624,000 line. Weights, not shares: a school calendar publishes instructional
    days, an exact integer, and the ratio is taken last.

  npm run check:pricing

Potomac computes $589,570.00 and not the stated $589,970.00; Chesapeake abstains
naming base.unitPrice; Anacostia is $649,792.00 with the calendar and unpriceable
without it; a 3-of-4-line bid withholds its total.

Migrations generated in two passes so drizzle-kit never needed the interactive
rename prompt. No SQL hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-08-04 02:39:20 -04:00
parent a36956b1e1
commit fdb0aeac5d
14 changed files with 7657 additions and 22 deletions

View File

@@ -0,0 +1,31 @@
CREATE TYPE "public"."scenario_variable_name" AS ENUM('siteCount', 'deliveriesPerWeek', 'instructionalWeeks');--> statement-breakpoint
CREATE TYPE "public"."exception_consequence" AS ENUM('price_delta', 'withdrawal', 'unquantified');--> statement-breakpoint
CREATE TYPE "public"."unpriced_reason" AS ENUM('shape_unsupported', 'input_missing', 'line_incomplete', 'withdrawal_conditioned');--> statement-breakpoint
CREATE TABLE "scenario_variable" (
"scenario_id" uuid NOT NULL,
"name" "scenario_variable_name" NOT NULL,
"value" numeric(14, 3) NOT NULL,
CONSTRAINT "scenario_variable_scenario_id_name_pk" PRIMARY KEY("scenario_id","name")
);
--> statement-breakpoint
ALTER TABLE "scenario_variable" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "derived_cost" ALTER COLUMN "amount" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "evaluation_scenario" ADD COLUMN "monthly_weights" integer[];--> statement-breakpoint
ALTER TABLE "price_line" ADD COLUMN "required" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "reason" "unpriced_reason";--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "amount_if_declined" numeric(16, 2);--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "reason_if_declined" "unpriced_reason";--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "breakdown" jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "evaluator_version" integer NOT NULL;--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "inputs_hash" text NOT NULL;--> statement-breakpoint
ALTER TABLE "derived_cost" ADD COLUMN "voided_by_amendment_id" uuid;--> statement-breakpoint
ALTER TABLE "exception" ADD COLUMN "target_section" text;--> statement-breakpoint
ALTER TABLE "exception" ADD COLUMN "labelled" boolean NOT NULL;--> statement-breakpoint
ALTER TABLE "exception" ADD COLUMN "consequence" "exception_consequence" NOT NULL;--> statement-breakpoint
ALTER TABLE "exception" ADD COLUMN "amount_if_declined" numeric(14, 2);--> statement-breakpoint
ALTER TABLE "price_quote" ADD COLUMN "attachment_document_id" uuid;--> statement-breakpoint
ALTER TABLE "scenario_variable" ADD CONSTRAINT "scenario_variable_scenario_id_evaluation_scenario_id_fk" FOREIGN KEY ("scenario_id") REFERENCES "public"."evaluation_scenario"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "price_quote" ADD CONSTRAINT "price_quote_attachment_document_id_response_document_id_fk" FOREIGN KEY ("attachment_document_id") REFERENCES "public"."response_document"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE POLICY "scenario_variable_read" ON "scenario_variable" AS PERMISSIVE FOR SELECT TO public USING (class_released(release_level_for_solicitation(
(SELECT solicitation_id FROM evaluation_scenario WHERE id = scenario_id)
), 'scenario_quantity'));

View File

@@ -0,0 +1 @@
ALTER TABLE "exception" DROP COLUMN "price_delta";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,20 @@
"when": 1785797388491, "when": 1785797388491,
"tag": "0003_criterion_nesting_and_determinability", "tag": "0003_criterion_nesting_and_determinability",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785825133701,
"tag": "0004_lucky_skreet",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785825144805,
"tag": "0005_petite_selene",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,13 +3,14 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Retail RFP transmission system v1 code layout", "description": "Retail RFP transmission system \u2014 v1 code layout",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"generate:custom": "drizzle-kit generate --custom", "generate:custom": "drizzle-kit generate --custom",
"migrate": "drizzle-kit migrate", "migrate": "drizzle-kit migrate",
"seams": "grep -rn 'new Seam(' src/ --include=*.ts" "seams": "grep -rn 'new Seam(' src/ --include=*.ts",
"check:pricing": "tsx src/pricing/corpus-check.ts"
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
@@ -18,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.23.5",
"typescript": "^5.7.0" "typescript": "^5.7.0"
} }
} }

View File

@@ -2,6 +2,8 @@ import { sql } from 'drizzle-orm'
import { import {
boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, text, timestamp, uuid, boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, text, timestamp, uuid,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import type { Derivation } from '../../pricing/evaluate.js'
import type { RateStructure } from '../../pricing/rate.js'
import { organization } from './org.js' import { organization } from './org.js'
import { evaluationScenario, priceLine, requirement, solicitation } from './solicitation.js' import { evaluationScenario, priceLine, requirement, solicitation } from './solicitation.js'
@@ -66,13 +68,42 @@ export const answer = pgTable('answer', {
}), }),
]).enableRLS() ]).enableRLS()
/**
* The consequence of an exception being DECLINED. Closed by #29.
*
* A nullable price delta was the obvious model and the wrong one. Anacostia
* labels three exceptions; two carry deltas, and the third says that if
* declined the vendor cannot bid produce at all. A nullable delta reads that
* third one as "no cost impact" — the inverse of the truth, on the most
* consequential of the three.
*/
export const exceptionConsequence = pgEnum('exception_consequence', [
'price_delta', 'withdrawal', 'unquantified',
])
/** Simultaneously a risk flag and a pricing line (#22). */ /** Simultaneously a risk flag and a pricing line (#22). */
export const exception = pgTable('exception', { export const exception = pgTable('exception', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
responseId: uuid('response_id').notNull().references(() => response.id), responseId: uuid('response_id').notNull().references(() => response.id),
requirementId: uuid('requirement_id').references(() => requirement.id), requirementId: uuid('requirement_id').references(() => requirement.id),
/**
* Set when the exception targets a document section the extractor never
* enumerated as a requirement — Anacostia's EX-3 points at C.3, which the
* truth file does not carry. That makes exceptions a completeness signal
* feeding #16's sweep, not merely a response field.
*/
targetSection: text('target_section'),
text: text('text').notNull(), text: text('text').notNull(),
priceDelta: numeric('price_delta', { precision: 14, scale: 2 }), /**
* False = found in prose with no heading and no delta, like Chesapeake's
* polite request inside the Pricing section. Detecting it is valuable;
* PRICING it would be a model deciding (#15), so an unlabelled exception
* never moves arithmetic — it routes to a human as a finding.
*/
labelled: boolean('labelled').notNull(),
consequence: exceptionConsequence('consequence').notNull(),
/** Set only for `price_delta`. */
amountIfDeclined: numeric('amount_if_declined', { precision: 14, scale: 2 }),
}, () => [ }, () => [
pgPolicy('exception_read', { pgPolicy('exception_read', {
for: 'select', for: 'select',
@@ -88,7 +119,15 @@ export const priceQuote = pgTable('price_quote', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
responseId: uuid('response_id').notNull().references(() => response.id), responseId: uuid('response_id').notNull().references(() => response.id),
priceLineId: uuid('price_line_id').notNull().references(() => priceLine.id), priceLineId: uuid('price_line_id').notNull().references(() => priceLine.id),
rateStructure: jsonb('rate_structure').notNull(), /**
* One base schedule plus closed modifiers — see src/pricing/rate.ts. Money
* leaves are nullable ON PURPOSE: Chesapeake's shape is fully supported and
* its unit price simply is not in the document, and "supported shape,
* missing field" is recoverable in a way "unrepresentable shape" is not.
*/
rateStructure: jsonb('rate_structure').$type<RateStructure>().notNull(),
/** Set when no supported shape represents this quote; the document stands in its place. */
attachmentDocumentId: uuid('attachment_document_id').references(() => responseDocument.id),
}, () => [ }, () => [
pgPolicy('price_quote_read', { pgPolicy('price_quote_read', {
for: 'select', for: 'select',
@@ -101,11 +140,51 @@ export const priceQuote = pgTable('price_quote', {
* Requirements contracts carry estimate-only quantities (#22), so anything * Requirements contracts carry estimate-only quantities (#22), so anything
* presenting this as contract value misrepresents comparability (#15). * presenting this as contract value misrepresents comparability (#15).
*/ */
export const unpricedReason = pgEnum('unpriced_reason', [
'shape_unsupported', 'input_missing', 'line_incomplete', 'withdrawal_conditioned',
])
export const derivedCost = pgTable('derived_cost', { export const derivedCost = pgTable('derived_cost', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
responseId: uuid('response_id').notNull().references(() => response.id), responseId: uuid('response_id').notNull().references(() => response.id),
scenarioId: uuid('scenario_id').notNull().references(() => evaluationScenario.id), scenarioId: uuid('scenario_id').notNull().references(() => evaluationScenario.id),
amount: numeric('amount', { precision: 16, scale: 2 }).notNull(), /**
* NULLABLE, and that is the decision. A price that cannot be computed is not
* zero — zero silently disqualifies on arithmetic — and it is not the
* vendor's own stated figure either. Chesapeake states the lowest number in
* the field, unverifiable, on the bid that fails two gates. It abstains and
* routes to a human, rendered achromatic and dashed like any indeterminate
* (#36). Null here is never a partial sum: if any required line abstains the
* whole total is withheld (#29, and #37's roll-up bug in money).
*/
amount: numeric('amount', { precision: 16, scale: 2 }),
reason: unpricedReason('reason'),
/** As-if-exceptions-declined. Shown beside `amount`; NEVER ranks (#29). */
amountIfDeclined: numeric('amount_if_declined', { precision: 16, scale: 2 }),
reasonIfDeclined: unpricedReason('reason_if_declined'),
/**
* The evidentiary half. A bare scalar can only assert; a protest asks WHY
* this bid ranked here, and the per-line derivation — band chosen, seasonal
* factor, each modifier's contribution, floor if it bound — is what answers.
*/
breakdown: jsonb('breakdown').$type<Derivation>().notNull(),
/**
* Recorded so a divergence found years later is explainable rather than
* merely alarming. Recomputation on read is a CHECK, not the authority: the
* stored number stays authoritative and a mismatch surfaces as a finding.
* This is not a promise that old evaluator versions stay runnable.
*/
evaluatorVersion: integer('evaluator_version').notNull(),
/** Covers the rate structures and the published scenario. Detects input drift. */
inputsHash: text('inputs_hash').notNull(),
/**
* A substantive amendment to the scenario VOIDS derived costs rather than
* recomputing them (#29, consistent with #16 and #37). Every figure here was
* computed against assumptions that no longer hold, and a quietly recomputed
* price is the silent recompute #15 forbids — more dangerous here than in
* scoring, because nobody watches arithmetic the way they watch a rubric.
*/
voidedByAmendmentId: uuid('voided_by_amendment_id'),
computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(), computedAt: timestamp('computed_at', { withTimezone: true }).notNull().defaultNow(),
}, () => [ }, () => [
pgPolicy('derived_cost_read', { pgPolicy('derived_cost_read', {

View File

@@ -146,15 +146,60 @@ export const priceLine = pgTable('price_line', {
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id), solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
label: text('label').notNull(), label: text('label').notNull(),
unit: text('unit').notNull(), unit: text('unit').notNull(),
/**
* A required line that cannot be priced withholds the whole bid total (#29).
* An optional one does not, which is the only reason this column exists.
*/
required: boolean('required').notNull().default(true),
}) })
/** Published with the RFP so price is bindable up front (#10). */ /**
* Published with the RFP so price is bindable up front (#10).
*
* `monthlyWeights` is relative volume per month, index 0 = January, in any
* consistent unit — instructional days, for a school food contract. WEIGHTS,
* not normalised shares: #29 found that rounding twelve shares to basis points
* quantizes a seasonal factor by enough to matter on a six-figure line, and
* #22 is emphatic that rounding erases results. Integers here, ratio taken in
* the evaluator, nothing rounded until the final total.
*/
export const evaluationScenario = pgTable('evaluation_scenario', { export const evaluationScenario = pgTable('evaluation_scenario', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id), solicitationId: uuid('solicitation_id').notNull().references(() => solicitation.id),
declaredAt: timestamp('declared_at', { withTimezone: true }).notNull().defaultNow(), declaredAt: timestamp('declared_at', { withTimezone: true }).notNull().defaultNow(),
monthlyWeights: integer('monthly_weights').array(),
}) })
/**
* The closed scenario vocabulary (#29). A rate structure may read only these,
* and one reading a variable this RFP never published is rejected at
* submission — while the vendor can still fix it — rather than resolving to a
* null months later when nobody can act on it.
*
* Curated the way the category spine is (#26): extending it is a schema change
* on purpose. Free-form keys would let two bids in one field be priced against
* different assumptions, which is the single thing derived cost exists to
* prevent.
*/
export const scenarioVariableName = pgEnum('scenario_variable_name', [
'siteCount', 'deliveriesPerWeek', 'instructionalWeeks',
])
/** Same disclosure class as the basket — these are the assumptions, and equally sensitive. */
export const scenarioVariable = pgTable('scenario_variable', {
scenarioId: uuid('scenario_id').notNull().references(() => evaluationScenario.id),
name: scenarioVariableName('name').notNull(),
value: numeric('value', { precision: 14, scale: 3 }).notNull(),
}, (t) => [
primaryKey({ columns: [t.scenarioId, t.name] }),
pgPolicy('scenario_variable_read', {
for: 'select',
using: sql`class_released(release_level_for_solicitation(
(SELECT solicitation_id FROM evaluation_scenario WHERE id = scenario_id)
), 'scenario_quantity')`,
}),
]).enableRLS()
/** /**
* The exact basket. Sits behind the acknowledgement gate (#14 decision 4) — * The exact basket. Sits behind the acknowledgement gate (#14 decision 4) —
* this is the sensitive half of the scenario, and its own disclosure class. * this is the sensitive half of the scenario, and its own disclosure class.

314
src/pricing/corpus-check.ts Normal file
View File

@@ -0,0 +1,314 @@
/**
* The corpus assertions for #29. `npm run check:pricing`
*
* This ticket is unusually verifiable: the Friendship PCS response set carries
* three rate structures with hand-computed ground truth, so the evaluator is
* falsifiable rather than merely reviewable. Stated figures are read from the
* truth files rather than typed here, so editing the corpus breaks this check
* instead of quietly diverging from it — the same discipline validate-truth.py
* applies to the RFP side.
*
* The three vendors exercise three different failure surfaces on purpose:
* Potomac the arithmetic is checkable AND THE DOCUMENT IS WRONG
* Chesapeake the shape is supported and the input is absent
* Anacostia cost depends on WHEN, and assuming flat flatters the bid
*
* NOTE what this does not cover: translating a PDF into these structures is
* extraction (#16), unbuilt. The structures below are transcribed by hand from
* the truth files, so this checks the EVALUATOR, not the extractor.
*/
import { readFileSync } from 'node:fs'
import { evaluateBid, evaluateLine, type LineDerivation } from './evaluate.js'
import { applyExceptions, type ResponseException } from './exception.js'
import type { RateStructure } from './rate.js'
import type { Scenario } from './scenario.js'
const CORPUS = 'corpus/friendship-pcs/responses'
const truth = (f: string) => JSON.parse(readFileSync(`${CORPUS}/${f}.truth.json`, 'utf8'))
const dollars = (minor: number) => `$${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`
let failures = 0
function check(label: string, ok: boolean, detail = '') {
console.log(` ${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`)
if (!ok) failures++
}
/* ── the school calendar ─────────────────────────────────────────────────── */
/** Instructional days by month, Jan..Dec. 180 days, none in July or August. */
const DAYS = [19, 18, 21, 18, 21, 10, 0, 0, 20, 22, 17, 14]
const TOTAL_DAYS = DAYS.reduce((a, b) => a + b, 0)
// Published as raw day counts. An earlier draft normalised these to
// basis-point shares and the factor assertion below failed by 3e-5 — about $19
// on this line. That was the SCENARIO quantizing, not the evaluator, and the
// fix was to stop quantizing: weights are exact, ratios are taken last.
const CALENDAR = DAYS
/* ── Potomac: volume-banded, whole basket ────────────────────────────────── */
console.log('\nPotomac Provisions — volume_banded, whole_basket')
const potomacTruth = truth('potomac-provisions')
const POTOMAC_VOLUME: number = potomacTruth.priceQuote.statedAtScenarioVolume
const POTOMAC_STATED: number = potomacTruth.priceQuote.statedBaseYearTotal
const potomac: RateStructure = {
base: {
kind: 'volume_banded',
application: 'whole_basket',
measuredBy: 'line_quantity',
bands: [
{ from: 0, to: 11_999, unitPrice: 2840 },
{ from: 12_000, to: 23_999, unitPrice: 2755 },
{ from: 24_000, to: 35_999, unitPrice: 2684 },
{ from: 36_000, to: null, unitPrice: 2627 },
],
},
modifiers: [],
}
const potomacScenario: Scenario = { quantities: { food: POTOMAC_VOLUME }, scalars: {} }
const p = evaluateLine('food', potomac, potomacScenario)
check(
'computes the band total exactly',
p.amount === 58_957_000,
`${dollars(p.amount ?? 0)} at ${POTOMAC_VOLUME} cases`,
)
// The planted trap. #15 decision 2 puts price arithmetic in code precisely so
// a vendor's stated total is never trusted over a computed one.
check(
'does NOT reproduce the stated total',
p.amount !== POTOMAC_STATED * 100,
`stated ${dollars(POTOMAC_STATED * 100)}, computed ${dollars(p.amount ?? 0)}, ` +
`divergence ${dollars(Math.abs((p.amount ?? 0) - POTOMAC_STATED * 100))}`,
)
// One word of data, thousands of dollars. If this ever passes, the flag has
// stopped being load-bearing and something is defaulting.
const marginal = evaluateLine(
'food',
{ ...potomac, base: { ...potomac.base, application: 'marginal' } as typeof potomac.base },
potomacScenario,
)
check(
'marginal application yields a different number',
marginal.amount !== null && marginal.amount !== p.amount,
`whole_basket ${dollars(p.amount ?? 0)} vs marginal ${dollars(marginal.amount ?? 0)}`,
)
// 12,000 units at the first band plus 9,400 at the second is exactly the
// 21,400 bid. An off-by-one at the band seam is invisible in the total and
// wrong in the record.
check(
'marginal prices every unit exactly once',
marginal.amount === 12_000 * 2840 + 9_400 * 2755,
dollars(marginal.amount ?? 0),
)
check('evaluation is deterministic', evaluateLine('food', potomac, potomacScenario).amount === p.amount)
/* ── Chesapeake: supported shape, absent input ───────────────────────────── */
console.log('\nChesapeake Food Service — flat_unit + per_event_charge, unit price absent')
const chesapeakeTruth = truth('chesapeake-food-service')
const CH_ASSUMED: { deliveriesPerWeek: number; sites: number } = chesapeakeTruth.priceQuote.statedAssumptions
const chesapeake: RateStructure = {
base: { kind: 'flat_unit', unitPrice: null }, // lives in the Excel sheet we never got (KM-1)
modifiers: [
{
kind: 'per_event_charge',
count: { product: ['siteCount', 'deliveriesPerWeek', 'instructionalWeeks'] },
rate: {
steppedBy: 'deliveriesPerWeek',
steps: [
{ at: 2, charge: 3400 },
{ at: 3, charge: 2850 },
{ at: 4, charge: 2400 },
],
},
},
],
}
const chesapeakeScenario: Scenario = {
quantities: { food: 21_400 },
scalars: {
siteCount: CH_ASSUMED.sites,
deliveriesPerWeek: CH_ASSUMED.deliveriesPerWeek,
instructionalWeeks: 36,
},
}
const c = evaluateLine('food', chesapeake, chesapeakeScenario)
check('abstains rather than returning a number', c.amount === null)
check('reason is input_missing, not shape_unsupported', c.reason === 'input_missing', String(c.reason))
check(
'names the field to ask the vendor for',
c.missing.includes('base.unitPrice'),
c.missing.join(', '),
)
// The lowest number in the field belongs to the bid that fails two gates. If a
// null cost ever falls back to the stated figure, the demo ranks a number
// nobody checked.
check(
'the stated total never substitutes for the computed one',
c.amount !== chesapeakeTruth.priceQuote.statedBaseYearTotal * 100,
`stated ${dollars(chesapeakeTruth.priceQuote.statedBaseYearTotal * 100)} is the cheapest in the field`,
)
/* ── Anacostia: seasonal, and the calendar that decides it ───────────────── */
console.log('\nAnacostia Farm Collective — flat_unit + seasonal_multiplier')
const anacostiaTruth = truth('anacostia-farm-collective')
const BASE_CASE_PRICE = Math.round(anacostiaTruth.priceQuote.rateStructure.baseProduceCasePrice * 100)
const PRODUCE_CASES = 20_000
const anacostia: RateStructure = {
base: { kind: 'flat_unit', unitPrice: BASE_CASE_PRICE },
modifiers: [
{
kind: 'seasonal_multiplier',
periods: [
{ months: [6, 7, 8, 9, 10], multiplier: 8800 },
{ months: [4, 5, 11], multiplier: 10_000 },
{ months: [12, 1, 2, 3], multiplier: 11_900 },
],
},
],
}
const noCalendar: Scenario = { quantities: { produce: PRODUCE_CASES }, scalars: {} }
const a0 = evaluateLine('produce', anacostia, noCalendar)
check('abstains when the calendar is not published', a0.amount === null)
check(
'names monthlyWeights as the missing input',
a0.missing.includes('scenario.monthlyWeights'),
a0.missing.join(', '),
)
const withCalendar: Scenario = { ...noCalendar, monthlyWeights: CALENDAR }
const a1 = evaluateLine('produce', anacostia, withCalendar)
const flat = BASE_CASE_PRICE * PRODUCE_CASES
check('prices once the calendar exists', a1.amount !== null, dollars(a1.amount ?? 0))
// The vendor says this themselves: the 180-day calendar falls disproportionately
// OUTSIDE the peak band. A flat assumption would have understated the bid.
check(
'the calendar makes this bid more expensive, not less',
(a1.amount ?? 0) > flat,
`flat would be ${dollars(flat)}, calendar-weighted is ${dollars(a1.amount ?? 0)}`,
)
// Independent arithmetic — float, weighted by raw day counts rather than by the
// apportioned bp shares the evaluator uses. Agreement to six figures means the
// traversal is right, not merely self-consistent.
const expectedFactor =
(52 * 0.88 + 56 * 1.0 + 72 * 1.19) / TOTAL_DAYS // peak / shoulder / out-of-region days
const actualFactor = (a1.amount ?? 0) / flat
check(
'the weighted factor matches an independent computation',
Math.abs(actualFactor - expectedFactor) < 1e-9,
`${actualFactor.toFixed(9)} vs ${expectedFactor.toFixed(9)}`,
)
/* ── the exception with no price ─────────────────────────────────────────── */
console.log('\nAnacostia exceptions — two priceable, one not')
const rawExceptions: {
id: string
labelled: boolean
requirementId: string | null
targetSection?: string
priceDeltaIfDeclined: number | null
isBidWithdrawalCondition?: boolean
}[] = anacostiaTruth.exceptions
const exceptions: ResponseException[] = rawExceptions.map((e) => ({
id: e.id,
labelled: e.labelled,
targetRequirementId: e.requirementId,
targetSection: e.targetSection ?? null,
consequence: e.isBidWithdrawalCondition
? { kind: 'withdrawal' }
: e.priceDeltaIfDeclined !== null
? { kind: 'price_delta', amountIfDeclined: e.priceDeltaIfDeclined * 100 }
: { kind: 'unquantified' },
}))
const bid = evaluateBid([{ priceLineId: 'produce', rate: anacostia, required: true }], withCalendar)
const adjusted = applyExceptions(bid, exceptions)
check('as-bid is the figure that ranks', adjusted.asBid === a1.amount)
check(
'the withdrawal exception makes as-if-declined undefined',
adjusted.asIfDeclined === null && adjusted.asIfDeclinedReason === 'withdrawal_conditioned',
`not ${dollars(anacostiaTruth.priceQuote.statedTotalIfExceptionsDeclined * 100)}, which omits EX-3`,
)
check(
'the exception pointing at an unenumerated section surfaces as a finding',
adjusted.findings.some((f) => f.targetSection === 'C.3'),
adjusted.findings.map((f) => f.id).join(', '),
)
/* ── Chesapeake's unlabelled exception never moves arithmetic ────────────── */
const chesapeakeExceptions: ResponseException[] = (chesapeakeTruth.exceptions as { id: string }[]).map((e) => ({
id: e.id,
labelled: false,
targetRequirementId: null,
targetSection: null,
consequence: { kind: 'unquantified' },
}))
const chBid = evaluateBid([{ priceLineId: 'food', rate: chesapeake, required: true }], chesapeakeScenario)
const chAdjusted = applyExceptions(chBid, chesapeakeExceptions)
console.log('\nChesapeake exception — unlabelled, hidden in the Pricing section')
check(
'an unlabelled exception is a finding, never an adjustment',
chAdjusted.findings.length === chesapeakeExceptions.length && chAdjusted.asIfDeclined === chBid.amount,
chAdjusted.findings.map((f) => f.id).join(', '),
)
/* ── a partial sum is never a total ──────────────────────────────────────── */
console.log('\nBid-level roll-up')
const mixed = evaluateBid(
[
{ priceLineId: 'produce', rate: anacostia, required: true },
{ priceLineId: 'grocery', rate: { base: { kind: 'flat_unit', unitPrice: 1500 }, modifiers: [] }, required: true },
{ priceLineId: 'dairy', rate: { base: { kind: 'flat_unit', unitPrice: 900 }, modifiers: [] }, required: true },
{ priceLineId: 'protein', rate: chesapeake, required: true },
],
{
...withCalendar,
quantities: { produce: PRODUCE_CASES, grocery: 4000, dairy: 3000, protein: 2000 },
scalars: chesapeakeScenario.scalars,
},
)
check('three of four lines price', mixed.linesPriced === 3, `${mixed.linesPriced}/${mixed.lineCount}`)
check('the bid total is withheld, not partial', mixed.amount === null && mixed.reason === 'line_incomplete')
const partial = mixed.lines.reduce((s: number, l: LineDerivation) => s + (l.amount ?? 0), 0)
check(
'the partial sum is a real number that must never be shown as a total',
partial > 0,
`${dollars(partial)} — arithmetically correct, and not this bid's price`,
)
/* ── */
console.log(failures === 0 ? '\nPRICING OK' : `\n${failures} FAILED`)
process.exit(failures === 0 ? 0 : 1)

424
src/pricing/evaluate.ts Normal file
View File

@@ -0,0 +1,424 @@
import {
BP_ONE,
MONTHLY_WEIGHTS,
type BaseSchedule,
type Minor,
type Modifier,
type RateStructure,
} from './rate.js'
import { assertWellFormed, type Scenario } from './scenario.js'
/**
* Rate evaluation. Closed by #29.
*
* Three properties this file exists to hold, all of them protest-facing:
*
* 1. ABSTENTION IS TYPED, NEVER ZERO. A cost that cannot be computed is null
* with a reason. Zero silently disqualifies on arithmetic; dropping the bid
* does it more quietly still. Neither is a decision the system may make
* (#15) — "this bid has no price" routes to a human, rendered achromatic
* and dashed like any other indeterminate (#36).
*
* 2. A PARTIAL SUM IS NEVER A TOTAL. If any required line abstains, the bid
* total is null and the surface shows `3/4 lines priced`. This is the
* roll-up bug #37 caught in the prototype — arithmetically correct, reads
* as a total, is not one. Same fix: one predicate, applied at the top.
*
* 3. ONE ROUNDING POINT. #22 banned rounding in scores because 82.15 and 81.90
* decide a contest; money is worse, because it is float by default. A line
* accumulates as an exact rational in bigint — numerator and denominator,
* never divided — and rounds once at the end, half-up. Nothing quantizes on
* the way through, which is what lets the seasonal factor be a ratio of
* published integers rather than a rounded multiplier.
*/
/**
* Bump on ANY change to the arithmetic. Recorded on every derivation so a
* divergence found years later is explainable rather than merely alarming.
* This is not a promise that old versions stay runnable — see the recompute
* rule in derivation storage (#29, and handed to #21).
*/
export const EVALUATOR_VERSION = 1
export type UnpricedReason =
/** No supported shape represents this. Permanent — the quote lives as an attachment. */
| 'shape_unsupported'
/** Shape is supported, a field is absent. RECOVERABLE: ask the vendor. */
| 'input_missing'
/** Bid-level only: at least one required line abstained. */
| 'line_incomplete'
/** An exception makes performance conditional, so the if-declined figure is undefined. */
| 'withdrawal_conditioned'
export interface Step {
readonly label: string
readonly amount: Minor | null
readonly detail?: string
}
export interface LineDerivation {
readonly priceLineId: string
readonly amount: Minor | null
readonly reason: UnpricedReason | null
/** Field paths that were absent. This is the clarification-request payload. */
readonly missing: readonly string[]
readonly steps: readonly Step[]
}
/**
* The stored record. The scalar alone is unfalsifiable — a protest asks WHY a
* bid ranked where it did, and a bare number can only assert. `steps` is what
* answers it.
*/
export interface Derivation {
readonly evaluatorVersion: number
readonly amount: Minor | null
readonly reason: UnpricedReason | null
readonly lines: readonly LineDerivation[]
readonly linesPriced: number
readonly lineCount: number
}
/* ── exact arithmetic ────────────────────────────────────────────────────── */
/** Quantities carry 3 decimals — numeric(14,3) in the schema. */
const QTY_SCALE = 1_000n
/**
* An exact rational in minor units. Never divided until `resolve`.
* Kept as a plain pair rather than reduced: the numbers here are small enough
* that bigint growth is irrelevant, and not reducing keeps the arithmetic
* obvious to whoever reads this during a challenge.
*/
interface Rational {
n: bigint
d: bigint
}
function roundHalfUp({ n, d }: Rational): number {
const neg = n < 0n
const a = neg ? -n : n
const q = (2n * a + d) / (2n * d)
return Number(neg ? -q : q)
}
function addMinor(r: Rational, amount: Minor): void {
r.n += BigInt(amount) * r.d
}
function compareMinor(r: Rational, amount: Minor): number {
const lhs = r.n
const rhs = BigInt(amount) * r.d
return lhs < rhs ? -1 : lhs > rhs ? 1 : 0
}
/* ── base schedule ───────────────────────────────────────────────────────── */
interface BaseResult {
readonly value: Rational | null
readonly missing: readonly string[]
readonly steps: readonly Step[]
}
function evaluateBase(base: BaseSchedule, quantity: number): BaseResult {
const qty = BigInt(Math.round(quantity * Number(QTY_SCALE)))
if (base.kind === 'flat_unit') {
if (base.unitPrice === null) {
return { value: null, missing: ['base.unitPrice'], steps: [] }
}
return {
value: { n: qty * BigInt(base.unitPrice), d: QTY_SCALE },
missing: [],
steps: [{ label: 'Unit price', amount: base.unitPrice, detail: `flat, x ${quantity} units` }],
}
}
if (base.application === 'whole_basket') {
// Potomac: a retroactive year-end rebate reprices EVERY unit at the band
// reached. The band is not knowable at order time, only at year end —
// which is exactly why derived cost is computed against the DECLARED
// scenario volume rather than anything observed.
const idx = base.bands.findIndex(
(b) => quantity >= b.from && (b.to === null || quantity <= b.to),
)
const band = base.bands[idx]
if (band === undefined) {
return { value: null, missing: [`base.bands (no band covers ${quantity})`], steps: [] }
}
if (band.unitPrice === null) {
return { value: null, missing: [`base.bands[${idx}].unitPrice`], steps: [] }
}
return {
value: { n: qty * BigInt(band.unitPrice), d: QTY_SCALE },
missing: [],
steps: [
{
label: 'Band unit price',
amount: band.unitPrice,
detail: `whole-basket band ${idx} [${band.from}, ${band.to ?? '∞'}] at ${quantity} units`,
},
],
}
}
// Marginal: each band prices only the volume falling inside it.
//
// The upper bound here is the NEXT band's `from`, not this band's `to`.
// `to` is inclusive and exists for whole-basket SELECTION, where bands read
// naturally as [0, 11999] then [12000, 23999]. Subtracting those bounds
// directly loses the unit between them — silently, since it is one unit out
// of tens of thousands. Marginal treats the ladder as half-open, the only
// reading that both totals correctly and survives fractional quantities.
// `bandsContiguous` is what keeps the two readings in agreement.
const missing: string[] = []
base.bands.forEach((b, i) => {
if (b.unitPrice === null) missing.push(`base.bands[${i}].unitPrice`)
})
if (missing.length > 0) return { value: null, missing, steps: [] }
const value: Rational = { n: 0n, d: QTY_SCALE }
const steps: Step[] = []
for (const [i, b] of base.bands.entries()) {
const next = base.bands[i + 1]
const lo = Math.max(b.from, 0)
const hi = next === undefined ? quantity : Math.min(next.from, quantity)
const inBand = Math.max(0, hi - lo)
if (inBand === 0) continue
value.n += BigInt(Math.round(inBand * Number(QTY_SCALE))) * BigInt(b.unitPrice!)
steps.push({
label: `Band ${i}`,
amount: b.unitPrice,
detail: `marginal, ${inBand} units in [${lo}, ${next === undefined ? '∞' : next.from})`,
})
}
return { value, missing: [], steps }
}
/* ── structure validation ────────────────────────────────────────────────── */
/**
* Bands must ascend and not overlap, and each closed `to` must sit directly
* below the next `from`. Whole-basket reads `to`; marginal reads the next
* `from`. If those two disagree the same ladder prices two different ways
* depending on a flag, which is not something to discover during a protest.
*/
function bandsContiguous(bands: readonly { from: number; to: number | null }[]): boolean {
for (const [i, b] of bands.entries()) {
const next = bands[i + 1]
if (next === undefined) continue
if (b.to === null) return false // only the last band may be open-ended
if (next.from <= b.from) return false
if (next.from - b.to !== 1) return false
}
return true
}
function coversTwelveMonthsExactly(m: Extract<Modifier, { kind: 'seasonal_multiplier' }>): boolean {
const seen = new Set<number>()
for (const p of m.periods) {
for (const month of p.months) {
if (month < 1 || month > 12 || seen.has(month)) return false
seen.add(month)
}
}
return seen.size === 12
}
/* ── seasonal ────────────────────────────────────────────────────────────── */
/**
* The seasonal multiplier is a weighted average factor over the published
* calendar, applied to the base subtotal — `sum(weight x multiplier) / (sum
* weight x BP_ONE)`, carried as an exact rational.
*
* Deliberate: it is exact, order-independent, identical to per-month
* computation whenever the unit price is uniform, and — the reason it is
* written this way — it composes with MARGINAL bands without anyone having to
* invent a rule for where a given month sits in the cumulative volume. That
* rule would be arbitrary, and arbitrary is not defensible in a protest.
*/
function seasonalFactor(
m: Extract<Modifier, { kind: 'seasonal_multiplier' }>,
weights: readonly number[],
): Rational {
let n = 0n
for (const p of m.periods) {
for (const month of p.months) {
n += BigInt(weights[month - 1] ?? 0) * BigInt(p.multiplier)
}
}
const total = weights.reduce((a, b) => a + b, 0)
return { n, d: BigInt(total) * BigInt(BP_ONE) }
}
/* ── line ────────────────────────────────────────────────────────────────── */
export function evaluateLine(
priceLineId: string,
rate: RateStructure,
scenario: Scenario,
): LineDerivation {
assertWellFormed(scenario)
const quantity = scenario.quantities[priceLineId]
if (quantity === undefined) {
return {
priceLineId,
amount: null,
reason: 'input_missing',
missing: [`scenario.quantities.${priceLineId}`],
steps: [],
}
}
if (rate.base.kind === 'volume_banded' && !bandsContiguous(rate.base.bands)) {
return {
priceLineId,
amount: null,
reason: 'shape_unsupported',
missing: ['base.bands (not a contiguous ascending ladder)'],
steps: [],
}
}
for (const m of rate.modifiers) {
if (m.kind === 'seasonal_multiplier' && !coversTwelveMonthsExactly(m)) {
return {
priceLineId,
amount: null,
reason: 'shape_unsupported',
missing: ['modifiers.seasonal_multiplier (periods do not cover all 12 months exactly once)'],
steps: [],
}
}
}
const base = evaluateBase(rate.base, quantity)
const missing = [...base.missing]
const steps: Step[] = [...base.steps]
let value = base.value
for (const m of rate.modifiers) {
if (m.kind === 'seasonal_multiplier') {
const weights = scenario.monthlyWeights
if (weights === undefined) {
// Never assume flat. Anacostia's own bid says a comparison ignoring the
// calendar flatters it, and assuming flat IS that comparison.
missing.push(`scenario.${MONTHLY_WEIGHTS}`)
value = null
continue
}
const f = seasonalFactor(m, weights)
if (value !== null) {
value = { n: value.n * f.n, d: value.d * f.d }
}
steps.push({
label: 'Seasonal factor',
amount: null,
detail: `${(Number(f.n) / Number(f.d)).toFixed(6)} x, weighted over the published calendar`,
})
continue
}
if (m.kind === 'fixed_fee') {
if (m.amount === null) {
missing.push('modifiers.fixed_fee.amount')
value = null
continue
}
if (value !== null) addMinor(value, m.amount)
steps.push({ label: 'Fixed fee', amount: m.amount })
continue
}
// per_event_charge
let events = 1
let unresolved = false
for (const v of m.count.product) {
const n = scenario.scalars[v]
if (n === undefined) {
missing.push(`scenario.scalars.${v}`)
unresolved = true
continue
}
events *= n
}
let charge: Minor | null
if (m.rate === null) {
charge = null
missing.push('modifiers.per_event_charge.rate')
} else if (typeof m.rate === 'number') {
charge = m.rate
} else {
const at = scenario.scalars[m.rate.steppedBy]
if (at === undefined) {
missing.push(`scenario.scalars.${m.rate.steppedBy}`)
charge = null
} else {
const step = m.rate.steps.find((s) => s.at === at)
if (step === undefined) {
// The vendor did not price this service level. Recoverable — ask.
missing.push(
`modifiers.per_event_charge.rate.steps (no step at ${m.rate.steppedBy}=${at})`,
)
charge = null
} else if (step.charge === null) {
missing.push(`modifiers.per_event_charge.rate.steps[at=${at}].charge`)
charge = null
} else {
charge = step.charge
}
}
}
if (charge === null || unresolved) {
value = null
continue
}
if (value !== null) addMinor(value, charge * events)
steps.push({
label: 'Per-event charge',
amount: charge,
detail: `x ${events} events (${m.count.product.join(' x ')})`,
})
}
if (value === null) {
return { priceLineId, amount: null, reason: 'input_missing', missing, steps }
}
if (rate.minimumCharge !== undefined && compareMinor(value, rate.minimumCharge) < 0) {
value = { n: BigInt(rate.minimumCharge), d: 1n }
steps.push({ label: 'Minimum charge applied', amount: rate.minimumCharge })
}
// The one rounding point. Everything above is exact.
return { priceLineId, amount: roundHalfUp(value), reason: null, missing: [], steps }
}
/* ── bid ─────────────────────────────────────────────────────────────────── */
export interface QuotedLine {
readonly priceLineId: string
readonly rate: RateStructure
/** A line the RFP requires priced. An optional line abstaining does not void the total. */
readonly required: boolean
}
export function evaluateBid(lines: readonly QuotedLine[], scenario: Scenario): Derivation {
const derived = lines.map((l) => evaluateLine(l.priceLineId, l.rate, scenario))
const linesPriced = derived.filter((d) => d.amount !== null).length
const requiredIncomplete = lines.some((l, i) => l.required && derived[i]!.amount === null)
return {
evaluatorVersion: EVALUATOR_VERSION,
// A partial sum is never shown as a total (#37's roll-up bug, in money).
amount: requiredIncomplete ? null : derived.reduce((sum, d) => sum + (d.amount ?? 0), 0),
reason: requiredIncomplete ? 'line_incomplete' : null,
lines: derived,
linesPriced,
lineCount: lines.length,
}
}

90
src/pricing/exception.ts Normal file
View File

@@ -0,0 +1,90 @@
import type { Derivation, UnpricedReason } from './evaluate.js'
import type { Minor } from './rate.js'
/**
* Exceptions. Closed by #29.
*
* An exception is a modification to the TERMS that sometimes carries a price
* consequence — not a price field. The corpus is unambiguous about why that
* distinction has to be structural: Anacostia labels three exceptions, two
* carrying deltas, and the third says that if declined the vendor cannot bid
* produce at all. A nullable `priceDeltaIfDeclined` reads that third one as
* "no cost impact", which is the inverse of the truth, and it happens to be
* the most consequential of the three.
*/
export type ExceptionConsequence =
| { readonly kind: 'price_delta'; readonly amountIfDeclined: Minor }
/** Cannot perform this scope if declined. A conditional withdrawal. */
| { readonly kind: 'withdrawal' }
/** Asserts a consequence, states no figure. Recoverable: ask. */
| { readonly kind: 'unquantified' }
export interface ResponseException {
readonly id: string
/**
* False = found in prose with no heading, no delta, no "exception" wording —
* Chesapeake's polite sentence inside the Pricing section. Detecting it is
* valuable; PRICING it would be a model deciding (#15), so an unlabelled
* exception never moves arithmetic. It routes to a human as a finding and
* renders indeterminate (#36), and the number does not change until a person
* says it does.
*/
readonly labelled: boolean
readonly targetRequirementId: string | null
/**
* Set when the exception points at a document section the extractor never
* enumerated as a requirement — Anacostia's EX-3 targets C.3, which the
* truth file does not carry. That makes exceptions a COMPLETENESS SIGNAL
* feeding #16's sweep, not merely a response field. Handed to #16.
*/
readonly targetSection: string | null
readonly consequence: ExceptionConsequence
}
export interface ExceptionAdjusted {
/**
* What the vendor is actually offering, and the ONLY figure that ranks.
* Whether to accept an exception is the retailer's call; folding it into the
* ordering would be the system pre-deciding it — the same boundary #15 draws
* around scoring.
*/
readonly asBid: Minor | null
/** Shown beside `asBid`. Never ranks. */
readonly asIfDeclined: Minor | null
readonly asIfDeclinedReason: UnpricedReason | null
/** Unlabelled exceptions, and exceptions pointing at unenumerated sections. */
readonly findings: readonly ResponseException[]
}
export function applyExceptions(
derivation: Derivation,
exceptions: readonly ResponseException[],
): ExceptionAdjusted {
const findings = exceptions.filter((e) => !e.labelled || e.targetSection !== null)
const priced = exceptions.filter((e) => e.labelled)
let asIfDeclined: Minor | null = derivation.amount
let asIfDeclinedReason: UnpricedReason | null = derivation.reason
for (const e of priced) {
if (e.consequence.kind === 'withdrawal') {
// Not zero, and emphatically not the as-bid figure carried over.
asIfDeclined = null
asIfDeclinedReason = 'withdrawal_conditioned'
break
}
if (e.consequence.kind === 'unquantified') {
asIfDeclined = null
asIfDeclinedReason = 'input_missing'
break
}
if (asIfDeclined !== null) asIfDeclined += e.consequence.amountIfDeclined
}
return {
asBid: derivation.amount,
asIfDeclined,
asIfDeclinedReason,
findings,
}
}

View File

@@ -1,5 +1,3 @@
import { Seam } from '../seam.js'
/** /**
* Rate function evaluation. Shape closed by #29. * Rate function evaluation. Shape closed by #29.
* *
@@ -9,18 +7,22 @@ import { Seam } from '../seam.js'
* - piecewise bands varying by category, threshold, or contract year * - piecewise bands varying by category, threshold, or contract year
* *
* This is deterministic by #15 decision 2 — a model may never evaluate it and * This is deterministic by #15 decision 2 — a model may never evaluate it and
* there is no fallback path. So the v1 rate structure types must be CLOSED and * there is no fallback path. #29 resolved the vocabulary as a COMPOSITION —
* computable: a shape the code cannot evaluate has nowhere to go. * one base schedule plus closed modifiers (rate.ts), evaluated in an order
* fixed in code (evaluate.ts) — with exceptions modelled separately, because
* one of the three in the corpus has no price at all (exception.ts).
*
* The greater-of shape above is the one deliberately deferred: it abstains
* with `shape_unsupported` rather than being half-built.
*
* The seam that stood here is gone. What replaced it is not `=> number`:
* evaluation produces a Derivation, whose amount is nullable with a typed
* reason, because the corpus contains a bid that cannot be priced at all and
* a system that returns a number for it is lying.
*
* npm run check:pricing # the corpus assertions
*/ */
export interface RateStructure { export * from './rate.js'
readonly kind: string export * from './scenario.js'
} export * from './evaluate.js'
export * from './exception.js'
export interface ScenarioQuantities {
[priceLineId: string]: number
}
/** Produces derived_cost: a comparison figure against the declared scenario, never contract value. */
export function evaluate(_rate: RateStructure, _q: ScenarioQuantities): number {
throw new Seam('#29', 'the v1 rate structure types')
}

170
src/pricing/rate.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* The v1 rate structure vocabulary. Closed by #29.
*
* Price is a rate function, not a scalar (#22), and #15 decision 2 bars any
* model from evaluating one — so every shape here must be computable by code
* with no fallback path. A structure the evaluator cannot reduce to a number
* does not degrade to a guess; it abstains (see evaluate.ts).
*
* COMPOSITION, NOT ENUMERATION. #29 rejected a closed list of whole shapes:
* the corpus already produced three bids that are the same object wearing
* different decoration (a unit price chosen by band, a unit price scaled by
* month, a unit price plus a per-stop charge), and enumerating whole shapes
* costs a cross-product. So a structure is ONE base schedule that decides a
* unit price, plus zero or more modifiers, plus an optional floor. Banded
* pricing that also varies by season composes for free — nobody designed that
* combination, and it is obviously real.
*
* Extending v1 means adding one member to one union. That was the point.
*/
/**
* Money is integer minor units (cents). No float ever holds a price.
* The evaluator accumulates in scaled bigint and rounds exactly once — see
* `roundHalfUp` in evaluate.ts, and #29's rounding rule.
*/
export type Minor = number
/** Basis points. 10_000 = 1.0. Multipliers are integers so the arithmetic is exact. */
export type Bp = number
export const BP_ONE: Bp = 10_000
/**
* The scenario vocabulary, curated the way the category spine is (#26).
*
* A rate structure may only read variables named here, and a structure that
* reads a variable the RFP did not publish is rejected at submission rather
* than resolving to null months later. This is the fairness constraint from
* #10 made structural: the scenario is published WITH the RFP, so every bid in
* a field is priced against identical assumptions. Let vendors reference
* arbitrary retailer-authored keys and two bids in one field can read
* different variables, which is precisely what derived_cost exists to prevent.
*
* Extending this is a schema change on purpose. A new category needing a new
* variable is a decision, not a config edit.
*/
export const SCALAR_VARIABLES = ['siteCount', 'deliveriesPerWeek', 'instructionalWeeks'] as const
export type ScalarVariable = (typeof SCALAR_VARIABLES)[number]
/**
* The one vector variable: how volume is spread across the twelve months,
* indexed 0 = January. Anacostia's bid is unpriceable without it, and — the
* vendor says so themselves — a comparison assuming flat distribution flatters
* that bid. Absence must abstain, never assume flat.
*
* WEIGHTS, NOT SHARES, and the corpus check is why. Expressed as basis-point
* shares, twelve rounded values quantize the seasonal factor by ~1e-4, which
* is real money on a six-figure line — and #22 is emphatic that rounding
* erases results. Weights are exact: a school calendar publishes instructional
* days per month, an integer nobody has to round, and the evaluator divides by
* their sum as the last step. Any consistent unit works (days, cases, weeks) —
* only the ratios are read.
*/
export const MONTHLY_WEIGHTS = 'monthlyWeights' as const
export type ScenarioVariable = ScalarVariable | typeof MONTHLY_WEIGHTS
/**
* MONEY LEAVES ARE NULLABLE, and that is the model rather than a weakness.
*
* #29 separated "we cannot represent this shape" from "the shape is supported
* and a field is missing," because the corpus contains both and only the
* second is recoverable. Chesapeake's structure is `flat_unit` plus a per-stop
* charge — fully supported — and its unit case price is simply absent, living
* in an Excel sheet the extractor never received (KM-1). Modelling that as an
* unrepresentable shape throws away the one actionable fact: somebody can go
* and ask for the number.
*
* So extraction produces a structure with holes, the evaluator names them, and
* `missing[]` is the clarification request. A parse that rejected the whole
* structure would lose which field to ask about.
*/
export interface Band {
readonly from: number
/** Inclusive; null is open-ended. */
readonly to: number | null
readonly unitPrice: Minor | null
}
export type BaseSchedule =
| { readonly kind: 'flat_unit'; readonly unitPrice: Minor | null }
| {
readonly kind: 'volume_banded'
/**
* EXPLICIT, NEVER INFERRED. Potomac's bands are whole-basket — a
* retroactive year-end rebate repricing every case at the band rate.
* Marginal tiers, where each tier prices only the volume inside it, are
* just as common elsewhere. One word of data, thousands of dollars of
* difference, and no default is right more than about half the time. An
* extractor that must state which it saw is better than one that guesses.
*/
readonly application: 'whole_basket' | 'marginal'
/**
* A single-member union on purpose: it names the axis so extending is
* additive and visible. SLC bands on a sales threshold rather than
* volume, which is the second member when that fog clears.
*/
readonly measuredBy: 'line_quantity'
readonly bands: readonly Band[]
}
/** How many chargeable events the scenario implies: the product of named variables. */
export interface EventCount {
readonly product: readonly ScalarVariable[]
}
/** A per-event charge that steps on a scenario variable, as Chesapeake's per-stop does. */
export interface SteppedCharge {
readonly steppedBy: ScalarVariable
/** Applied where the variable equals `at`. Exact match — these are discrete service levels. */
readonly steps: readonly { readonly at: number; readonly charge: Minor | null }[]
}
export type Modifier =
/**
* Scales the volume term per period rather than the total, so cost depends
* on WHEN volume falls. Requires `monthlyDistribution`.
*/
| {
readonly kind: 'seasonal_multiplier'
/** Months are 1-12. Every month must be covered exactly once. */
readonly periods: readonly { readonly months: readonly number[]; readonly multiplier: Bp }[]
}
/** Additive. Chesapeake's per-stop delivery charge; also per-order, per-site mobilisation. */
| {
readonly kind: 'per_event_charge'
readonly count: EventCount
readonly rate: Minor | null | SteppedCharge
}
/** Additive, flat for the term. */
| { readonly kind: 'fixed_fee'; readonly amount: Minor | null }
export interface RateStructure {
readonly base: BaseSchedule
readonly modifiers: readonly Modifier[]
/** Applied to the subtotal, last. Absent means no floor. */
readonly minimumCharge?: Minor
}
/**
* DEFERRED, deliberately: `greater_of` — SLC's MAX(1/12 x minimum annual
* guarantee, % of gross receipts). It is genuinely recursive (two complete
* structures under a MAX), it belongs to concessions rather than supply, and
* SLC extraction is still fog on the map. Until it clears, a bid shaped that
* way falls to `shape_unsupported` and abstains, which is the correct outcome
* for a shape we cannot evaluate — not a half-built one we can evaluate wrong.
*/
/** Which scenario variables a structure reads. Drives submission-time validation. */
export function requiredVariables(rate: RateStructure): ScenarioVariable[] {
const out = new Set<ScenarioVariable>()
for (const m of rate.modifiers) {
if (m.kind === 'seasonal_multiplier') out.add(MONTHLY_WEIGHTS)
if (m.kind === 'per_event_charge') {
for (const v of m.count.product) out.add(v)
if (m.rate !== null && typeof m.rate === 'object') out.add(m.rate.steppedBy)
}
}
return [...out]
}

57
src/pricing/scenario.ts Normal file
View File

@@ -0,0 +1,57 @@
import {
MONTHLY_WEIGHTS,
type RateStructure,
type ScalarVariable,
type ScenarioVariable,
requiredVariables,
} from './rate.js'
/**
* The published scenario. #10 put it in the RFP so price is bindable up front,
* and #29 made its variable set closed (see rate.ts).
*
* Note what a scenario variable IS: an assumption declared for comparison, not
* a contract term. Chesapeake's delivery frequency is changeable mid-term on
* two weeks' notice, so the awarded price will not match the derived cost and
* nobody should be surprised by that. Naming it an assumption here is cheaper
* than explaining it during a protest.
*/
export interface Scenario {
/** Per price line. Fractional quantities are fine; the evaluator scales to integers. */
readonly quantities: Readonly<Record<string, number>>
readonly scalars: Readonly<Partial<Record<ScalarVariable, number>>>
/**
* Relative volume per month, index 0 = January. Non-negative integers in any
* consistent unit — instructional days is the natural one for a school food
* contract. Only the ratios are read, so nothing here needs normalising and
* therefore nothing here gets rounded.
*/
readonly monthlyWeights?: readonly number[]
}
/**
* Submission-time validation: a structure that reads a variable this RFP did
* not publish is rejected NOW, while the vendor can still fix it — not
* resolved to a null in the evaluator months later when nobody can act on it.
*/
export function missingVariables(rate: RateStructure, scenario: Scenario): ScenarioVariable[] {
return requiredVariables(rate).filter((v) =>
v === MONTHLY_WEIGHTS
? scenario.monthlyWeights === undefined
: scenario.scalars[v] === undefined,
)
}
/**
* A malformed published scenario is a bug, not a bid outcome — so this throws
* rather than abstaining. Abstention is reserved for facts about the response.
*/
export function assertWellFormed(scenario: Scenario): void {
const w = scenario.monthlyWeights
if (w === undefined) return
if (w.length !== 12) throw new Error(`monthlyWeights has ${w.length} entries, expected 12`)
if (w.some((n) => !Number.isInteger(n) || n < 0)) {
throw new Error('monthlyWeights must be non-negative integers')
}
if (w.reduce((a, b) => a + b, 0) <= 0) throw new Error('monthlyWeights sum to zero')
}