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:
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
3013
migrations/meta/0003_snapshot.json
Normal file
3013
migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user