Apply both corpus findings to the schema

criterion.parent_id — a score commits at any criterion with NO WEIGHTED
CHILDREN. Derived from the rubric, never configured, and neither corpus
document is special-cased: Tarrant's five flat criteria are all leaves;
Friendship's 'Method of Approach' 30 is not scoreable while its 15/10/5
children are. A criterion whose sub-items carry no points stays scoreable
at its own level, because those are requirements mapped through
criterion_requirement rather than children.

requirement.determinable_from_response — false where satisfaction cannot be
established from the response document at all. Gate evaluation excludes
these rather than failing them; treating Friendship's 'two bid copies' as
not_answered disqualifies two of three bidders over a packaging detail.

The scoring rule is mirrored in corpus/validate-truth.py so the corpus and
the code cannot silently disagree. Against Friendship it yields 10 scoreable
criteria of 12, summing to exactly 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian
2026-08-03 18:50:16 -04:00
parent 19e7ffe92a
commit 126e94a14b
6 changed files with 3115 additions and 0 deletions

View File

@@ -53,6 +53,19 @@ if pts != list(range(t['ratingScale']['min'], t['ratingScale']['max'] + 1)):
else: else:
print(f" ok rating scale {pts[0]}-{pts[-1]}, {len(pts)} anchors") print(f" ok rating scale {pts[0]}-{pts[-1]}, {len(pts)} anchors")
# The scoring-level rule, mirrored from src/scoring/routing.ts: a score commits
# at any criterion with NO WEIGHTED CHILDREN. Printed so the corpus and the code
# cannot silently disagree about which criteria a committee actually scores.
scoreable = [c for c in t['criteria']
if not any(k['parentId'] == c['id'] and (k.get('maxPoints') or 0) > 0
for k in t['criteria'])]
sc_total = sum(c['maxPoints'] for c in scoreable)
print(f" ok scoreable criteria {len(scoreable)} of {len(t['criteria'])}, "
f"points sum {sc_total}")
if sc_total != tot:
errs.append(f"scoreable points sum {sc_total} != declared total {tot}")
print(" " + ", ".join(f"{c['id']}({c['maxPoints']})" for c in scoreable))
mapped = {c for r in t['requirements'] for c in r.get('criterionIds', [])} mapped = {c for r in t['requirements'] for c in r.get('criterionIds', [])}
for cid, c in crit.items(): for cid, c in crit.items():
kids = [x for x in t['criteria'] if x['parentId'] == cid] kids = [x for x in t['criteria'] if x['parentId'] == cid]

View File

@@ -0,0 +1,3 @@
ALTER TABLE "criterion" ADD COLUMN "parent_id" uuid;--> statement-breakpoint
ALTER TABLE "requirement" ADD COLUMN "determinable_from_response" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "criterion" ADD CONSTRAINT "criterion_parent_id_criterion_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."criterion"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1785792003081, "when": 1785792003081,
"tag": "0002_seed_release_matrix", "tag": "0002_seed_release_matrix",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785797388491,
"tag": "0003_criterion_nesting_and_determinability",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,6 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { import {
type AnyPgColumn,
boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, primaryKey, boolean, integer, jsonb, numeric, pgEnum, pgPolicy, pgTable, primaryKey,
text, timestamp, uuid, text, timestamp, uuid,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
@@ -68,6 +69,23 @@ export const requirement = pgTable('requirement', {
provenancePage: integer('provenance_page'), provenancePage: integer('provenance_page'),
provenanceSpan: jsonb('provenance_span').$type<{ start: number; end: number }>(), provenanceSpan: jsonb('provenance_span').$type<{ start: number; end: number }>(),
/**
* False where satisfaction cannot be established from the response document
* at all — a fact about submitting rather than about content. Copy counts,
* delivery method, envelope marking, receipt by a stated hour.
*
* Found by the corpus, not by design: Friendship PCS requires "two bid
* copies". One synthesized bidder asserts it in prose and two do not, and no
* better extraction would change that. Coverage over such a requirement is
* neither answered nor not_answered but UNKNOWABLE FROM THE ARTIFACT.
*
* Gate evaluation must exclude these rather than fail them — treating the
* Friendship case as not_answered disqualifies two of three bidders over a
* packaging detail. The vendor must also never be warned they will fail a
* gate their document was never able to prove (#16).
*/
determinableFromResponse: boolean('determinable_from_response').notNull().default(true),
confirmationState: confirmationState('confirmation_state').notNull().default('unconfirmed'), confirmationState: confirmationState('confirmation_state').notNull().default('unconfirmed'),
confirmedBy: uuid('confirmed_by'), confirmedBy: uuid('confirmed_by'),
confirmedAt: timestamp('confirmed_at', { withTimezone: true }), confirmedAt: timestamp('confirmed_at', { withTimezone: true }),
@@ -76,10 +94,33 @@ export const requirement = pgTable('requirement', {
/** /**
* The scale is per-document and must never be normalised (#22, #10). * The scale is per-document and must never be normalised (#22, #10).
* The prose anchor is the load-bearing part, not the integer. * The prose anchor is the load-bearing part, not the integer.
*
* ── SCORING LEVEL ───────────────────────────────────────────────────────
*
* A score commits at any criterion with NO WEIGHTED CHILDREN.
*
* Derived from the rubric, never configured. Two real solicitations forced
* this rule and neither is special-cased:
*
* Tarrant County five flat criteria, no nesting — all five are leaves.
* Friendship PCS "Method of Approach" 30 is NOT scoreable; its children
* (15/10/5) are. Likewise "Experience" 20 over 5/5/5/5.
*
* A criterion whose sub-items carry NO points stays scoreable at its own
* level — those sub-items are requirements mapped through
* `criterion_requirement`, not children. Friendship carries both shapes in
* one document, which is why the line is drawn on *weight* rather than on
* nesting.
*
* Roll-up is consistent by construction: weighted children sum to their
* parent's max_points, and corpus/validate-truth.py asserts it.
* ────────────────────────────────────────────────────────────────────────
*/ */
export const criterion = pgTable('criterion', { export const criterion = pgTable('criterion', {
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),
/** Null at the top level. A grouping label that also yields a roll-up total. */
parentId: uuid('parent_id').references((): AnyPgColumn => criterion.id),
label: text('label').notNull(), label: text('label').notNull(),
maxPoints: numeric('max_points', { precision: 8, scale: 2 }).notNull(), maxPoints: numeric('max_points', { precision: 8, scale: 2 }).notNull(),
weight: numeric('weight', { precision: 8, scale: 2 }).notNull(), weight: numeric('weight', { precision: 8, scale: 2 }).notNull(),

View File

@@ -50,3 +50,41 @@ export function requiresIndividualConfirmation(
): boolean { ): boolean {
return kind === 'mandatory_gate' || isCriterionMapped return kind === 'mandatory_gate' || isCriterionMapped
} }
/**
* Whether a gate can be evaluated against a response document at all.
*
* Confirmation is unaffected — the retailer still confirms the requirement
* exists. What changes is GATE EVALUATION: a requirement whose satisfaction is
* invisible to the artifact must be excluded rather than failed, or a bidder is
* disqualified for something their document could never have proven.
*
* Found by the corpus. See the column comment on `requirement`.
*/
export function gateEvaluableFromResponse(r: {
kind: Kind
determinableFromResponse: boolean
}): boolean {
return r.kind === 'mandatory_gate' && r.determinableFromResponse
}
/**
* A score commits at any criterion with NO WEIGHTED CHILDREN.
*
* Derived from the rubric rather than configured — see the block comment on
* `criterion`. A child with `maxPoints` of zero or null is guidance, not a
* weighted child, and does not make its parent unscoreable.
*/
export function isScoreable(
criterionId: string,
all: readonly { id: string; parentId: string | null; maxPoints: number | null }[],
): boolean {
return !all.some((c) => c.parentId === criterionId && (c.maxPoints ?? 0) > 0)
}
/** The set a committee actually commits scores against. */
export function scoreableCriteria<
T extends { id: string; parentId: string | null; maxPoints: number | null },
>(all: readonly T[]): T[] {
return all.filter((c) => isScoreable(c.id, all))
}