The problem

Large tenders don’t ask for a document. They attach a workbook, a supplier response schedule, and require it back, filled in. Fifteen sheets, several hundred rows, a compliance dropdown per row, a comment column beside it, merged cells, embedded images, and a header block written by whoever built the template.

That constraint is the whole engineering problem, and it is unusual in three ways.

01

The output format is not yours

The deliverable is the buyer’s own file with answers in it. You cannot normalise it, restructure it, or rebuild it from a clean template, because a rebuilt workbook loses their formatting, their images and their dropdown validations, and some procurement portals reject the result.

02

The template arrives empty

Every answer cell is blank by definition. So any code that decides what a row means by looking at the cells around it is blind on precisely the file the product exists to process. This defeated the row classifier in a way no test on a completed workbook would ever expose.

03

A cell has a vocabulary

A compliance column accepts one of a handful of values and nothing else. Prose in that cell is not a worse answer. It is a value the workbook’s own validation will reject, in a file being submitted to a buyer.

04

Silence is the dangerous failure

A bid manager submits what the export produced. A row that was deliberately left blank, and a row nobody noticed was blank, look identical in a spreadsheet. So the export’s account of what it withheld matters as much as what it wrote.

What I built

Already existed
  • A questionnaire pipeline that extracted questions from documents and answered them from a retrieval corpus
  • A canonical answer cache and a tiered answering path
  • A reader that surveyed spreadsheets and classified rows as questions or non-questions
  • A model for a resolved answer layout, per-cell answers and their encryption, all of it defined and none of it wired to anything
What I added
  • Per-cell answering: the sheet’s resolved column contract passed into the prompt, one value generated per column, each validated against that column’s own allowed values
  • The signals that let the row classifier tell a form field from a section header in an empty template, and the sheet-wide correction after the first version of one of them backfired
  • An in-place export writer over the original workbook, preserving styles, merges, dropdowns and images
  • A sticky review flag, so a refusal recorded during generation survives every later relabelling of the row
  • Export notices that name the affected rows, including a bucket for rows carrying a rating with no supporting comment
  • Idempotent re-processing, keyed on each row’s absolute sheet coordinates, preserving human edits
  • Resumable runs: a claim on the workbook, a heartbeat, a resume worker, and cancellation that reaches an in-flight model call
One row of a supplier response schedule, upload to delivery
Buyer’s workbook, as suppliedmany sheets · merged cells · dropdownsthe deliverable is *their* file, edited,so nothing may be rebuilt or rewrittenSurvey the sheet · resolve columnsquestion columns vs answer columnsthe template arrives empty.every gate that reads neighbouringcells is blind here (see exhibit 1)Classify each rowrequirement · header · field · junksection headers must not be answered,form fields must be. both are shortnoun phrases in an empty columnAnswer: cache, or retrievaldropdown columns bypass the cachethe cache holds one prose string, so achoice cell has to be generated.those tabs pay retrieval on every rowValidate: refuse, don’t coerceoff-vocabulary → withheld, flaggeda value the workbook’s own dropdownwould reject is worse than a gap.the refusal is the useful outputWrite into the workbookin place: styles and merges keptrebuilding the file loses the buyer’sformatting, so the writer edits thefile it was givenNotices: name rows, not countsunanswered · flagged · unjustified ratingwhatever stage 5 withheld must surfacehere by row. a refusal nobody is toldabout is indistinguishable from an answerevery failure below is a stage-ordering failure: a signal that discriminates here, not laterthe file that comes back is the file that went in, with answers and an honest account of the gaps

Four problems worth describing

01

Nine rows marked “skipped” that nobody skipped

Symptom

On the first real workbook, a third of the first sheet came back skipped, including every basic company detail: organisation name, postal address, telephone, email, the name of the signing representative. The company profile holding all of those values was populated and correctly wired. Nobody had clicked skip all.

Diagnosis

Those rows had never reached the answering step at all. The reader had classified them as section banners, meaning decoration, and rows classified as decoration are excluded from answering entirely, which the interface then surfaces as skipped.

The reason was the classifier’s very first gate: does this row have other cells filled in? If so, it is a real question. That gate is sound on the path it was written for, where the file being read is a completed questionnaire. On an unfilled returnable template every answer cell is blank by definition, so the gate can never fire, and short noun-phrase labels then fall through the remaining tests (under the length limit, under the word limit, no question mark, no requirement verb) and land in a weak structural test where a merged cell or a dense identifier column tips them to banner.

Worse, one signal was actively voting against them. The sheet’s merged value cell was being read as evidence for banner, because the merge-detection code kept each merge’s width and discarded its starting column. So a merge to the right of the question, which is what an answer cell looks like, was indistinguishable from a merge across the question itself, which is what a section header looks like.

And the safety net didn’t fire: a circuit breaker disables junk detection on a sheet when too many rows are flagged, but this was a third of them, under the threshold.

Decision

Add signals, never replace the gate. That gate is live negative evidence on the shared questionnaire path, where it is right; the new signals are additive escape hatches that run after it. Three of them, and I checked which rows each one actually rescued before writing any:

a dropdown rule covering this row’s cell to the right of the question; a merge starting right of the question column, which needed the merge code to retain the starting column it was already computing and throwing away; and a vocabulary test for fill-in field labels.

The vocabulary test is the ugliest of the three and it stays, because the table of which signal rescues which row is unambiguous. The structural signals rescue the dropdown rows and the merged rows, and leave every unmerged free-text row still skipped: the address, the phone number, the representative. Those were the rows that started the investigation.

The test asserts the fix and asserts the circuit breaker did not fire, because a green test that passed via the safety net proves nothing about the signals.

The general shape: a heuristic that reads surrounding content is only as good as its assumption about what surrounds it. This one had been written against filled-in files and was correct there. Nothing was wrong with it except the input it now got, which is why it passed review, and why the bug appeared only on real customer templates.

02

A signal that discriminated perfectly, then discriminated nothing

Symptom

On the next workbook, the opposite failure. Four rows that were section headers, bare parent numbers with no requirement text under them, were each answered at two to five thousand characters, and the export wrote compliance ratings into the sheet’s section headers. Not one row on that sheet was classified as a non-question.

Diagnosis

The dropdown signal from exhibit 1 was rescuing them. On a form, a dropdown beside a row is strong evidence that the row is a field to fill in. On a compliance matrix, the author paints the same dropdown down the entire column, so every row satisfied the signal, including the headers, and each one was rescued as a “field row” before the classifier ever consulted the fact that the row was a parent identifier with children beneath it.

A signal present on every row of a sheet carries no information about any row of that sheet. It had been genuinely discriminating on the file it was built for and was pure noise on this one.

I did not diagnose that from reading the code. There was an existing harness that runs the reader over a directory of real workbooks and prints per-sheet counts; I extended it to print each row’s evidence flags and classification reason, so the mechanism came off the actual file rather than out of my own reasoning about it.

Decision

Three changes, each scoped to what it actually knew.

Precedence. A parent identifier with children beneath it outranks the field-row escape, because it is derived from the sheet’s own numbering and a form field never has children. But it is placed below the length and requirement-verb gates, so a numbered parent that carries a real requirement sentence stays answerable.

Coverage. Compute per-column dropdown coverage once per sheet and stop a column that covers nearly all question rows from counting as per-row evidence. With a floor of ten question rows, because on a short vertical form an author painting a dropdown down a five-row answer column would trip any percentage threshold, and short vertical forms are exactly the rescue in exhibit 1 that this must not break.

A direct reading beats an inference. A row whose question text is identical to its own row identifier has no requirement in it to answer, whatever the structural evidence says, so it is classified before any of the structural work, where no rescue can reach it, and it is exempt from the circuit breaker. The breaker exists to catch a structural inference misfiring across a sheet; on an identifier-heavy sheet, counting these would trip it and un-junk everything, which is the exact failure being fixed.

I also wrote down the fix I rejected: a general regular expression for bare identifiers. It matches published standard names and annex numbers, and a compliance matrix whose question column lists bare standards is a real shape on these files, so every row of such a sheet would be discarded, with the breaker exempt and therefore unable to rescue them. The narrow equals-its-own-identifier test survived; the general pattern did not.

03

A refusal that a later line of code relabelled as an answer

Symptom

Found by comparing a full machine-answered submission against the same submission answered by people. Six rows had shipped a compliance rating with an entirely empty justification column, and every one of them was labelled in the interface as a clean, sourced answer. Nothing flagged them. A bid manager reading the review queue would never have looked at them.

Diagnosis

Those rows had been refused on purpose. The answer named a supporting attachment the bid did not have, and the guard against claiming attachments correctly withheld the justification and marked the row for a human.

Then two later blocks in the same assembly function overwrote the label. One sets a source attribution whenever the row has citations; the other sets one whenever the row needs a document, unconditionally. Both ran after the guard, and both erased the review status. Attachment-claim refusals happen precisely on document-request rows, so the second block hit every affected row.

The refusal was being inferred from fields that later code was free to overwrite, and it left no trace at export time either: the guard had already dropped the refused cell, so the stored row was internally consistent and re-validating it at export raised nothing at all.

Decision

Track the refusal explicitly, as its own sticky flag, and gate both relabelling blocks on it. A source label describes where an answer came from; it is not a licence to clear a review flag, because the cell being attributed is the one that was refused. Citations and match provenance stay populated in every case, since they are what the human writing the real answer needs.

The obvious one-line version of this check is wrong, and I noted why in the code rather than in a commit message: gating on “the answer is non-empty” passes, because the validator sets the row’s answer to the primary cell’s value, which on these sheets is the compliance code. Non-empty check, check passes, row relabelled, justification still blank.

Two things I chose not to do. No backfill, since this corrects generation, so rows already stored with the wrong label stay wrong until they are regenerated. That is worth stating rather than discovering, because the validation run resets the affected sheets and will therefore look clean while real bids elsewhere are still wrong. And no decision in code about whether an unapproved row’s values should reach the customer’s file at all: the export warns and writes, since the refused value was already withheld and what remains is a validated one, while withholding it leaves a mandatory dropdown blank that some portals reject. That is a product call, so I raised it as one.

Six rows carrying a compliance rating with no justification
A refusal inferred from overwritable fields
guard withholds the justification, flags the row   ✓
then: citations present      → source relabelled
then: row needs a document   → source relabelled
                               (unconditionally)
stored row      clean, sourced, internally consistent
review queue    absent
export notice   none. nothing to key off at export
delivered file  a rating, and an empty comment cell
A refusal tracked as its own fact
guard withholds the justification, flags the row   ✓
refusal recorded as its own flag: sticky
both relabel blocks gated on it
citations + match provenance kept either way
stored row      pending, needs a human
review queue    present
export notice   named by row, not counted
not the gate    “answer is non-empty”. the answer
                is the rating. it passes. ← the trap
04

A confidence score that said “high” about one column in three

Symptom

Answers crammed into one cell on sheets that had three or four separate answer columns, with no warning anywhere. The layout resolver reported high confidence in each case.

Diagnosis

The confidence switch granted high as soon as one strong answer column was identified, however many plausible blank candidate columns it had left unclaimed. Measured against the real files, it was finding one of three columns, and three of four, and calling both high.

That number is load-bearing further down than it looks. The export trusts only a high or human-confirmed layout, and suppresses its “this layout was inferred from the sheet’s shape and never confirmed” warning when it sees one. So the single signal that would have sent somebody to the column editor was being switched off by the same over-confidence that created the need for it.

Decision

Split it into two pieces of work and do the small honest one first: degrade to medium when unclaimed blank candidate columns remain to the right of a claimed one, and return those candidates, because the strong branch was returning none, so degrading alone would have left the editor with a warning and nothing to offer. Widening detection to actually claim those columns is the larger, riskier change, and it goes after the correction path exists, not before.

The same principle drove the export’s notices, which were bare counts (“3 rows are unanswered”) with no row identity. They became lists of row labels, capped with the true total preserved and the full list logged, across four buckets including a new one for a row carrying a rating with no supporting comment. The wording is aimed at a bid manager about to submit, so it names the risk rather than the mechanism.

A separate, smaller lesson from the same export: the workbook it produced could not be opened by a common Python spreadsheet library, which some downstream tooling used. A style round-trip in the Go library was appending an empty fill element that the Python parser rejects. It looked like data being lost, and the fix was the opposite of what it looked like: zero the fill rather than preserve it, since the empty element was what preserving it produced. I confirmed that in a throwaway reproduction before touching the export, because “Excel opens it fine” had already made this invisible once.

Where it landed

9company-detail rows silently marked skipped, and what led to the classifier work
1 of 3answer columns detected while reporting “high” confidence
6rows that shipped a rating with no justification, labelled clean
169machine-written rows compared line by line against the same rows written by people
Verified
  • Per-cell answering: the resolved column contract reaches the prompt, values are generated per column and validated against each column’s own allowed values, and off-vocabulary values are withheld rather than written
  • A choice-shaped sheet bypasses the answer cache entirely, because the cache holds a single prose string and structurally cannot fill a dropdown. Accepted at the cost of paying retrieval on every row of those sheets
  • An in-place export writer over the buyer’s original workbook, preserving styles, merged cells, dropdown validations and images
  • Refusals survive relabelling, and appear in the review queue and in a named export notice
  • Export notices identify rows rather than counting them, with the true total preserved when the list is capped
  • Row classification distinguishes form fields from section headers on an empty template, with the fix and the disabled-safety-net assertion both tested
  • Re-processing is idempotent, keyed on each row’s absolute sheet coordinates, refreshing machine state while keeping every human-edited answer, assignment and approval
  • Runs are resumable: a claim taken on the workbook rather than the sheet, a heartbeat so a long healthy run is not stolen, a bounded attempt count, and cancellation that reaches an in-flight model call within seconds instead of minutes
  • A real-workbook harness that prints per-row classification evidence, which is how exhibit 2 was diagnosed
What this doesn't solve
  • The classifier is a pile of structural heuristics plus a vocabulary fallback. It is tested against real files and it will meet a template shape that defeats it. The circuit breaker bounds the damage rather than preventing it.
  • One resolved layout per sheet. A sheet containing two tables with different answer columns answers one of them against the other’s contract. Deferred deliberately, because making a sheet carry several layouts changes the model, the writer, the editor and the interface together.
  • A row that nobody approved still has its validated values written into the delivered file, with a blocking-severity warning that does not block the download. Withholding them leaves a mandatory dropdown empty, which some portals reject. That trade is a product decision and I left it as one.
  • Rows already stored with the wrong review label keep it until they are regenerated. There is no backfill, and the validation run resets the affected sheets, so it looks clean while older bids stay wrong.
  • The word-document schedule path shares none of the export’s honesty work. It builds its own notices, so an unapproved row there still exports silently.
  • The system has no concept of *who* is answering. Comparing against the human submission, most of the apparent under-claims were the human answering on behalf of a partner where the machine answered on behalf of the company. That is a modelling gap, not a prompt gap, and it needs a product decision before code.

What I'd do differently

Ask what the input looks like in production before trusting a heuristic that reads context. The row classifier was correct code with a wrong assumption. It inferred meaning from neighbouring cells, and its new input had none. That single mismatch produced exhibit 1, and the signals I added to fix it produced exhibit 2.

Treat a confidence score as a claim that has to be checked, not as output. High was being reported on one column in three, and downstream code was using it to suppress the warning that would have caught it. A confidence value nothing validates is worse than no confidence value, because other code starts depending on it.

Make the pipeline’s own decisions visible earlier. Both classification exhibits were diagnosed only after I added per-row evidence output to a test harness. Before that, the only trace was an aggregate count per sheet with no row identity, and a row the classifier abstained on left no trace at all. Every stage that makes a per-row judgement should be able to say why, on demand, before it is trusted with a customer’s file.