Define the common schema for RFPs and responses #10
Notifications
Due Date
No due date set.
Blocks
Depends on
#14 Specify the two-party data release rules
christian/helmdocs-proposal-system
#16 Design the extraction confirmation step on both sides
christian/helmdocs-proposal-system
#15 Design AI bid ranking, scoring, and its defensibility
christian/helmdocs-proposal-system
#19 Draw the HelmDocs integration boundary
christian/helmdocs-proposal-system
#18 Define the retailer integration and export paths
christian/helmdocs-proposal-system
#23 Design the app framework — retailer and vendor surfaces
christian/helmdocs-proposal-system
#25 Obtain real completed vendor responses, including a compliance matrix
christian/helmdocs-proposal-system
#26 Define the category taxonomy
christian/helmdocs-proposal-system
Reference: christian/helmdocs-proposal-system#10
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Question
The load-bearing domain model for the whole system. What is a requirement? What is a rubric criterion? What is a response element, and how does it bind to the requirement it answers?
Both sides submit documents and both get extracted, so this schema is the target of two different extractors and the substrate for ranking, matching, analytics, and the cross-retailer profile. It has to be expressive enough to hold what real retail RFPs contain — pricing tables, narrative, attachments, certifications, volume tiers — and rigid enough that a hundred vendors' responses are genuinely comparable.
Prior art to mine, not copy:
packages/core/src/solicitation-extraction/schema.tsand the compliance-matrix-builder agent in thenovelpad-desktoprepo.Resolve: the entity model, the requirement<->response binding, how retailer-specific fields coexist with the common core, and what is deliberately left unstructured.
Parent: #1
Resolution
Four decisions, then the model they imply. Sized for a demoable POC per the amended destination —
lightweight, but with teeth in the places where a naive schema quietly produces wrong awards.
1. Requirement is the atom; criteria are a weighted overlay
Real solicitations carry two structures that do not line up. A requirement list — what a
vendor must do, provide, or comply with. And an evaluation rubric — weighted criteria the bid is
scored against. They are authored by different people for different purposes, and they are not in
1:1 correspondence: one criterion ("Technical Approach, 40 pts") spans a dozen requirements, while
many requirements ("provide a current certificate of insurance") carry no score at all and are
pure pass/fail gates.
So: an RFP decomposes into requirements, each with a kind —
mandatory/scored/informational. Criteria are a separate weighted structure, each mapping to a set ofrequirements. A response binds answers to requirement ids; ranking rolls those answers up
through the mapping to criteria.
Why not make the criterion the atom. Mandatory-but-unscored requirements would have nowhere to
live — and those are precisely the ones that disqualify a bid. A vendor scoring 95 who never
supplied a W-9 would rank first, with the disqualification that actually decides the award invisible
to the system.
Why not the section or question. Section structure is document formatting, not meaning, and it
differs on every RFP. Binding to it would optimise for the one thing that never needs to be
portable, and cross-RFP comparability, answer reuse, and matching would all lose their footing.
This also matches what the existing
compliance-matrix-builderagent already does, so thebidder-side thinking transfers rather than being re-derived.
What it costs. Two structures plus a mapping is the most schema of the three options, and
authoring the mapping is real work on every RFP — a step that can be wrong.
2. The document is legally authoritative; the confirmed extraction is authoritative for evaluation
Retailers arrive with a finished RFP document — settled by the distribution-layer decision — so the
requirement list and criterion mapping must come out of that document. But in real procurement the
document is the binding artifact: if the extracted list diverges from the PDF and an award is
protested, the PDF wins.
So extraction proposes; the retailer confirms; the confirmed structure is what ranking,
matching, and compliance checking run on, while the document remains binding. Confirmation is a
recorded event with an actor and a timestamp.
Teeth: every requirement carries a provenance pointer into the source document — document
id, page, character span — plus a confirmation state. Any ranking claim therefore traces back to a
location a human can look at.
Why this over native authoring. Authoring requirements natively would give perfect data and no
divergence, but it contradicts the distribution-layer decision and asks a retailer to move authoring
off a process built around legal review and templates — the largest adoption ask available, made to
the side that pays nothing.
Why this over advisory-only annotations. Nothing would be comparable across RFPs, ranking would
degrade to per-document summarisation, and the cross-retailer premise would have no structure to
stand on.
What it costs. Confirmation is friction on the retailer, who is the free side and least tolerant
of work. And nothing guarantees completeness — a requirement missed entirely by extraction never
appears for anyone to confirm. That is an unfixed hole, and it is the strongest argument for the
extraction confirmation surface being good rather than adequate.
3. Coverage is three-state, and the vendor attests to their own
Binding is a gradient — from a tabbed compliance matrix restating every requirement verbatim,
down to free prose answering three requirements in one paragraph while naming none. So "no answer
bound to this requirement" is ambiguous between the vendor did not answer (which can disqualify)
and we failed to find it (which is our defect). Collapsing those disqualifies vendors for
extraction bugs.
Each requirement therefore carries a coverage state per response:
answered— bound, with a provenance span in the vendor's documentnot_answered— extraction searched and is confident nothing addresses itindeterminate— binding unreliableOnly
not_answeredmay drive disqualification.indeterminatealways escalates, neverpenalises.
The vendor resolves their own indeterminates at submit — the same move as the vault decision:
the vendor attests, we validate structure. A bid therefore arrives with coverage the vendor has
stood behind, and a later dispute is between the vendor and their own attestation rather than
between the retailer and our extractor.
What it costs. Three states propagate everywhere downstream — ranking, the compliance matrix UI,
the audit record. And the confidence line between
not_answeredandindeterminateis a tuningparameter that will be wrong at first; it should be observable and adjustable rather than baked in.
4. Price is a rate function evaluated against an RFP-declared scenario
Real bids do not price as a number. They price as unit rates, volume bands, tiers, minimums, and
surcharges — and two vendors can each be cheaper than the other depending on how much is actually
bought. Comparability is not a property of the bid; it exists only relative to an assumed volume.
Which means: whoever picks the volume assumption picks the winner.
So a price line is a typed rate structure, never a scalar. The RFP declares an evaluation
scenario — the volume basket price will be judged against — as part of the solicitation, so
vendors know the basis before they bid. Comparable cost is computed at evaluation time from rate
function plus scenario, and stored as a derived value carrying the scenario id it came from.
Why declared up front rather than chosen at evaluation. Choosing the basket after prices are
known makes it an unpublished lever applied to known bids — the exact pattern a losing vendor
protests, and not a position this product should be standing in. Published up front, the winner is a
function of a stated assumption, and re-running against a different scenario is a visible act
rather than a quiet re-rank.
Why not leave price unmodelled. Price is usually the heaviest-weighted criterion in the rubric.
An unmodelled price means the ranking engine abstains on the thing that decides the award, which
undercuts the central claim that responses are machine-comparable.
What it costs. Retailers must state a volume basket up front, and some genuinely do not know it.
The rate-structure type is the most intricate object in the schema and will need extending —
graduated to its own ticket.
Carried forward unchanged from the artifacts research
Scoring scales are per-document and are never normalised. Scores are stored as raw
points / max_pointsagainst the criterion that issued them. There is no cross-RFP score and noglobal vendor rating derived from scores. A 92 on one solicitation and an 88 on another are not
comparable and the schema must not offer a field that implies they are.
The model
Lightweight but with teeth. Roughly fourteen entities.
Solicitation side
Response side
exceptionis deliberately first-class. Exceptions and qualifications taken against the retailer'sterms are what real evaluators care about most, and they are the thing an invented corpus always
forgets.
Deliberately absent: any cross-RFP score, any normalised rating, any scalar total price, any
requirement hierarchy, and any structure keyed to document sections.
Category reference:
solicitation.local_category_idpoints at the retailer-local categoryper the taxonomy decision — not the spine leaf. The spine is reached through the mapping.
Constraints handed to other tickets
answer.text,price_quote.rate_structure, andexception.textare thecompetitively sensitive fields; release rules key off
(response, requirement)granularity, notwhole documents.
criterion_requirement; must representindeterminaterather than scoring it; consumes
derived_cost, neverrate_structuredirectly.resolving indeterminate coverage. The completeness hole in decision 2 is that surface's problem.
derived_cost.scenario_idare allprotest surface.
state is its primary visual.
tickets.
Two notes from a skeptical review pass
1.
criterion_requirementis authored, not extracted, and has no provenance. Every other objectin this model traces to a document span. Real solicitations almost never state which requirements
roll up to which criterion — the mapping is implicit. So decision 1 asks the retailer not to
confirm an extraction but to create information the binding document does not contain, and
decision 2's "the document is legally authoritative" has nothing to be authoritative over here.
Decision 1 called the mapping "real work on every RFP", which understates it: it is the one place
the system manufactures structure. Under protest, why was this requirement scored under Technical
Approach answers only "a retailer clicked it".
Consequences to carry: the mapping needs its own attribution (who mapped, when, and whether the
model proposed it), and ranking explanations must visibly distinguish document-derived from
human-authored structure. Handed to the extraction confirmation step and
ranking.
2. The evaluation scenario is the most commercially sensitive field in the solicitation.
Decision 4 publishes the volume basket with the RFP so price is bindable up front.
Visibility names volume commitments as exactly what the acknowledgement gate protects, and
that gate is optional per RFP — so with it off, the basket reaches the entire roster.
Not a contradiction, but the release timing is unspecified and neither ticket noticed the
interaction. Handed to data release.
Corpus finding:
criterioncannot represent a nested rubricRaised as an amendment rather than absorbed silently, per #30's plan. Found while extracting
Friendship PCS.
The schema has
criterion (id, solicitation_id, label, max_points, weight)with no parent. Tworeal documents in the corpus disagree about which level carries the score, and the schema can only
express one of them.
Tarrant County scores at the top level - five flat criteria, no children:
Friendship PCS has weighted sub-criteria that sum to their parent:
Flattening to the leaves loses the parent label the document itself names, and loses the fact that
30 points is the unit a committee may score against. Keeping only the parent loses the weighting.
Suggested shape: a nullable
parent_criterion_id, plus an explicit rule for which level scoresare committed at - because #15's commit model needs to know, and #16's confirmation ledger counts
"criterion-mapped" requirements against it.
Not a gap, and worth recording as validated: the same document also has sub-bullets that carry
no points - under Proven Organizational Capacity and Community Connection. Those are guidance, not
sub-criteria, and
criterion_requirementalready models them correctly. The design holds; onlyweighted nesting is missing.
Second, smaller finding. Friendship's "Geographic preference - 5 points" embeds a mandatory
submission inside a scored criterion: "Bidder must submit certification of percentage of locally
grown or raised foods." A scored criterion carrying a pass/fail artifact. #15's routing decides by
requirement kind, so this needs to be two requirements mapped to one criterion rather than one
requirement of ambiguous kind.
Second corpus finding: a mandatory gate no response document can satisfy
Surfaced by
validate-responses.pywhile checking the Friendship PCS response set - by thevalidator, not by design, which is the point of having one.
R-B6C- "Two bid copies submitted" is on the Bidder Checklist and is therefore a mandatorygate. It is also a physical submission fact. One of three synthesized bidders happens to assert
it in prose; the other two simply do not, and no better extraction would change that.
Coverage over this requirement is neither
answerednornot_answered. It is unknowable from theartifact. #10's three-state coverage has no cell for it, and the nearest available state is
actively wrong: treating it as
not_answereddisqualifies two of three bidders over a packagingdetail.
This is not a rare shape. Anything about the act of submitting rather than the content of the
submission behaves the same way - copy counts, delivery method, envelope marking, receipt by a
stated hour. Real solicitations are full of them and they are genuinely mandatory.
Suggested shape: a
determinable_from_responseflag onrequirement, so gate evaluation canexclude these rather than fail them. It also matters to #16 - a vendor should not be shown "you
will fail a gate" for something their document was never able to prove - and to #15, whose gate
routing is
codeand would otherwise return a confident wrong answer.The corpus records it as
requirementNotes.gatesNotDeterminableFromResponse, and the validatorexcludes those from its gate check, so the shape is already exercised.
Amendment: the scoring level, and two schema columns
Both corpus findings are now applied - commit
126e94a.The scoring level, decided
A score commits at any criterion with NO WEIGHTED CHILDREN.
Derived from the rubric, never configured, and neither corpus document needs special-casing:
Likewise "Experience" 20 over 5/5/5/5.
A criterion whose sub-items carry no points stays scoreable at its own level, because those
sub-items are requirements mapped through
criterion_requirementrather than children. Friendshipcarries both shapes in one document, which is why the line is drawn on weight rather than on
nesting - identical visual indentation, two different meanings.
Why not score at the parent. It discards weights the issuer published, and #15 has to commit
against something the document actually declares.
Why not make it configurable. The rubric already answers it. A per-solicitation setting would
let a retailer score at a level the document does not support, and an award turns on it.
Consistency is structural rather than enforced: weighted children sum to their parent's
max_points. Against Friendship the rule yields 10 scoreable criteria of 12, summing to exactly100 -
corpus/validate-truth.pyasserts it, so the corpus and the code cannot silently drift.Columns added
determinable_from_responseis the second finding:R-B6C"two bid copies" is a mandatory gate noresponse document can establish. Gate evaluation excludes these rather than failing them -
treating it as
not_answereddisqualifies two of three synthesized bidders over a packaging detail.Confirmation is unaffected; the retailer still confirms the requirement exists.
Handed on:
gateEvaluableFromResponse()andscoreableCriteria()insrc/scoring/routing.ts.#16 must also never warn a vendor they will fail a gate their document could not have proven.