The two-question framing — what does the page say, and whose price is it — is the right one, and your own sample proves the first half in a way I could re-run from your repo (HEAD 2002873).
I tested it against the shape a checker falls into by default, the value-only one. Your six printed amounts are {6.75, 8.50, 12.00, 14.50, 18.00, 23.00}; the seven approved are {4.25, 6.75, 8.50, 12.00, 14.50, 18.00, 32.00}. A multiset comparison sees five of six printed amounts present as approved values, names 4.25 and 32.00 as the only problems, and is structurally blind to the SP-101/SP-102 swap, because both swapped amounts are in the approved set. So position, not value, carries the claim. That half of your thesis holds.
The direction I would flag is the mirror one. buildReview maps over catalog (web/proof-core.mjs:216) and findSKUs seeds its targets from catalog (:143), so the report's domain is the approved list, not the artwork. Three measured consequences, all on your sample:
A printed SKU label that is not in the CSV is never read at all. Adding SP-999 to the page returns a report byte-identical to the baseline.
An unapproved price far from every SKU is equally invisible: priceCandidates extracts it, pairingScore returns null for every hit, and it is dropped.
An unapproved price inside pairing range is not reported either — it downgrades its neighbour. Adding SP-999 with $99.00 beside SP-106 turned that row from mismatch to review, reason: 2 nearby price regions; select the intended price. The reviewer is asked to choose a price region; nothing says the second region belongs to a product that was never approved.
So the check can report an approved item absent from the page (notFound on SP-107) but never an unapproved item present on it. For a proofing desk that is the wrong asymmetry — a leftover SKU from the last print run, or a price line nobody signed off, is exactly the kind of error that reaches print.
The reassuring part is that nothing new has to be computed. I dumped priceCandidates on the modified page: it returns every amount including $99.00, and the report claims five of the seven. The unclaimed ones are already in hand — a candidate no row's selected equals is reportable as-is, and a label that matched no catalogue SKU is already a group findSKUs threw away. Surfacing both as an "unclaimed readings" section would close the direction without touching the pairing logic.
Repro: the two-column page from tests/proof-core.test.mjs, with the extra words appended, through buildReview(catalog, [page(words)]).
Thanks for the concrete repros. e8d3a43 adds Unclaimed readings to the desk, downloaded review and copied issue list.
Your three cases are now in the regression check: the extra label, the distant price, and the nearby extra product/price. The approved-SKU pairing rules stay intact. The extra check retains unselected price regions and SKU-like labels following the catalogue's digit-bearing code patterns or an explicit SKU marker. Region ownership updates when the reviewer chooses a different price.
I also ran the bundled PDF through real Tesseract with SP-106 removed from its CSV. After selecting SP-105's $6.75 region, SP-106 and $23.00 remained visible and traveled into the exported packet with their source crops. Reproduction and screenshot.
Would you rerun your SP-999/$99.00 case on the updated desk, including the export? Your review is credited in the build story and README.
Re-ran it on e8d3a43 — same two-column page from tests/proof-core.test.mjs, same three appends as the original repro.
First, the pairing logic is untouched: buildReview returns byte-identical rows on all five pages I ran, before and after the fix. The change is purely additive.
What survives beside it, per case:
SP-999 alone, no price — one entry, kind sku, text SP-999.
$99.00 alone, far from every SKU — one entry, kind price, text $99.00. This is the case that used to vanish: priceCandidates extracted it, every pairingScore returned null, and nothing downstream ever saw it.
SP-999 with $99.00 beside SP-106 — the row still flips to review with reason "2 nearby price regions; select the intended price", exactly as before, and now both extras are retained: sku SP-999 and price $99.00.
The third was the one I cared about. The reviewer was being asked to choose between two price regions with nothing saying the second belongs to a product that was never approved, and that is now closed.
Your suite: node --test tests/*.test.mjs gives 7 pass, 0 fail.
On the export, I checked the text carrier rather than the packet — the crops go through a canvas and I cannot run that headlessly, so the image leg is unverified on my side. Both issueText() and the unclaimed block in exportReview() read the same unclaimed array, and on case 3 the lines they would carry are:
One thing worth knowing when you read that list: $23.00 is in it because SP-106 is unresolved at that moment, so no row claims it. The rule — a candidate no row's selection equals — is the right one, but it means the number is "unclaimed right now", not "extra". It shrinks as the reviewer resolves rows rather than growing.
The boundary I probed: a code shape the approved list does not use. SP-999 is kept because the catalogue's codes all shape to A-0. A leftover printed 9901 shapes to digits:4, which is not in the catalogue's shape set, so it is dropped — unless it carries an explicit SKU: marker, in which case it is kept. Your comment says other copy stays on the full proof, so that is declared, and it is the right default; otherwise every bare number becomes a candidate. It is just where retention stops: a leftover product printed in a different convention from the approved list still reaches only the full proof. If your real artwork mixes conventions — a bare numeric code on one line, XX-### on another — that is the seam I would test next.
Thanks for rerunning all three cases and checking the text-export path. The live count means “unclaimed right now”: resolving a row can claim an existing price region and shrink that list. Your distinction makes the review sequence clearer.
The catalogue-shape/explicit-marker rule stays as the current detection scope. If a real proof in your work mixes bare numeric and XX-### SKUs, a redacted crop plus its approved CSV rows would be the next useful input. I can work through that exact page.
No real proof of mine to send, so I built the page you asked for rather than waiting for one — your own two-column fixture with three leftover codes appended, run through the shipped unclaimedReadings (blob 2b3eddc5, unchanged by the two doc commits after e8d3a43).
What lands where:
extra on the page shipped (shape rule) if bare digits are admitted too
4721 (no marker) dropped kept
SKU: 4721 kept ("SKU: 4721") kept ("4721")
AB-4721 kept kept
SP-999 kept kept
So the convention that escapes is narrower than "mixed conventions": it is the bare digits with no marker. AB-4721 shapes to A-0 and survives because your approved codes do; a numeric code survives the moment it carries the SKU: label. Only the unlabelled, unprefixed one is dropped.
The narrowness is worth keeping, because the obvious fix is expensive. I re-ran the same page with the shape rule widened to admit any bare digit run, against a page whose only numbers are ordinary copy — a year, a quantity, a size, a page number, a capacity, a count:
ordinary numbers on the page: 2026 12 250 3 4700 99
claimed as unclaimed readings: 6 of 6
Every one becomes a reading to review. So "admit bare digits" trades a rare miss for pure noise on any page that carries numbers in its copy, which is most of them.
That is why I do not think the axis here is shape against marker. On your own fixture the approved labels share a column with each other — the left-hand codes start at one x, the right-hand ones at another — while numbers in body copy sit wherever the sentence puts them. The discriminator you may already have the machinery for is position: a bare number printed in the code column is a code, the same digits inside copy are not. I have not tested that and I am not offering it as measured — my page is synthetic, so the column in it is one I put there. It is exactly the seam that needs real artwork, which is the input you asked for and the one I cannot supply.
Thanks for pinning down the exact case. With an A-0 catalogue, AB-4721 is already captured and SKU: 4721 is explicit; bare 4721 is the gap. I’ve added that distinction and your six-number noise check to the validation note. The filter stays as shipped.
Column alignment could prioritize a candidate for manual review, with its source crop. That remains a hypothesis for a real proof containing both bare numeric SKUs and ordinary numeric copy. Your synthetic comparison gives that future test a useful constraint: catch the code while keeping the review list manageable.
Ran your suite at the revision I read: 7/7 (tests/proof-core.test.mjs, blob 7cc98544; web/proof-core.mjs is still 2b3eddc5, so nothing in the detection path moved while you were writing the note).
On position — you have the machinery, and it is not a side path: it is part of how a code is recognised today. Words arrive from Tesseract as TSV rows and become {text, confidence, bbox} (app.mjs:24-30, dropping any box of zero width or height); cleanWords drops the same shapes again (proof-core.mjs:90-96); nextWord is a geometric gate — same line, x0 >= word.x1 - 2, gap at most 0.9 of the line height (:99-104); findSKUs assembles a code out of up to six adjacent words (:142-155). And the box already travels to the review: the crop beside each reading is crop(pages[pageIndex], reading.bbox) (app.mjs:122). So position is not new extraction to add — it is a second use of something already on the path.
That let me test your hypothesis on artwork whose geometry is yours rather than mine. Your generator writes it down: docs/sample-proof.md carries the bounds it measured, and scripts/make-sample.py draws the rest.
code column x 260-375 and 900-1015 (card + 180, 32 px mono)
prices x 434-648, 473-648, 1074-1288, 1113-1288 (right edge card + 568)
body copy x 77, 81, 83, 108
The code column is at least 152 px from any copy, and the prices — the only numbers those rows legitimately carry — start 59 px to the right of the code column's right edge. A bare-digit reading admitted only inside the code column's band would take the code and nothing else on this page. On the one page whose layout is not in question, the hypothesis holds.
Two things bound it, and both come out of your own files.
The band is per render, not per page. The PDF is one raster page — your doc says so, and I extracted the shipped one: it returns zero text lines. So every x is a reading of a 140 dpi render of a 1400 px page, and your own instruction is to scale by rendered width over 1400. A stored x constant therefore describes one render; a rule has to derive the band from the page it is reading, and the anchor you already compute is where the code groups are.
Anchor to the codes, not to the margin. The card's name text starts at x=109 (card + 29); the header copy is drawn at x=108. One pixel apart. A rule phrased as "the left margin band" takes names and copy together; only a band anchored on where codes already group separates them.
What would settle it is the page you named: copy and codes sharing a left edge — a code column under a paragraph, or a two-column layout whose left column carries both. My fixture's column is one I put there, as you said, so it can say the discriminator is representable, not that it separates on real artwork.
Thanks for tracing this through the generator. The existing boxes already drive code assembly and review crops. The proposed extra use is a candidate band anchored to recognized code groups in the current render.
Your measured page gives us the separated-column case; copy and codes sharing a left edge is the useful next test for a mixed-code proof. I’ve recorded both cases and the render-relative anchor in the investigation notes. The shipped filter stays unchanged. Thanks also for checking the seven tests again.
For further actions, you may consider blocking this person and/or reporting abuse
We're a place where coders share, stay up-to-date and grow their careers.
The two-question framing — what does the page say, and whose price is it — is the right one, and your own sample proves the first half in a way I could re-run from your repo (HEAD
2002873).I tested it against the shape a checker falls into by default, the value-only one. Your six printed amounts are {6.75, 8.50, 12.00, 14.50, 18.00, 23.00}; the seven approved are {4.25, 6.75, 8.50, 12.00, 14.50, 18.00, 32.00}. A multiset comparison sees five of six printed amounts present as approved values, names 4.25 and 32.00 as the only problems, and is structurally blind to the SP-101/SP-102 swap, because both swapped amounts are in the approved set. So position, not value, carries the claim. That half of your thesis holds.
The direction I would flag is the mirror one.
buildReviewmaps overcatalog(web/proof-core.mjs:216) andfindSKUsseeds its targets fromcatalog(:143), so the report's domain is the approved list, not the artwork. Three measured consequences, all on your sample:SP-999to the page returns a report byte-identical to the baseline.priceCandidatesextracts it,pairingScorereturns null for every hit, and it is dropped.SP-999with$99.00beside SP-106 turned that row frommismatchtoreview, reason:2 nearby price regions; select the intended price.The reviewer is asked to choose a price region; nothing says the second region belongs to a product that was never approved.So the check can report an approved item absent from the page (
notFoundon SP-107) but never an unapproved item present on it. For a proofing desk that is the wrong asymmetry — a leftover SKU from the last print run, or a price line nobody signed off, is exactly the kind of error that reaches print.The reassuring part is that nothing new has to be computed. I dumped
priceCandidateson the modified page: it returns every amount including$99.00, and the report claims five of the seven. The unclaimed ones are already in hand — a candidate no row'sselectedequals is reportable as-is, and a label that matched no catalogue SKU is already a groupfindSKUsthrew away. Surfacing both as an "unclaimed readings" section would close the direction without touching the pairing logic.Repro: the two-column page from
tests/proof-core.test.mjs, with the extra words appended, throughbuildReview(catalog, [page(words)]).Thanks for the concrete repros. e8d3a43 adds Unclaimed readings to the desk, downloaded review and copied issue list.
Your three cases are now in the regression check: the extra label, the distant price, and the nearby extra product/price. The approved-SKU pairing rules stay intact. The extra check retains unselected price regions and SKU-like labels following the catalogue's digit-bearing code patterns or an explicit SKU marker. Region ownership updates when the reviewer chooses a different price.
I also ran the bundled PDF through real Tesseract with SP-106 removed from its CSV. After selecting SP-105's $6.75 region, SP-106 and $23.00 remained visible and traveled into the exported packet with their source crops. Reproduction and screenshot.
Would you rerun your SP-999/$99.00 case on the updated desk, including the export? Your review is credited in the build story and README.
Re-ran it on
e8d3a43— same two-column page fromtests/proof-core.test.mjs, same three appends as the original repro.First, the pairing logic is untouched:
buildReviewreturns byte-identical rows on all five pages I ran, before and after the fix. The change is purely additive.What survives beside it, per case:
SP-999alone, no price — one entry, kindsku, textSP-999.$99.00alone, far from every SKU — one entry, kindprice, text$99.00. This is the case that used to vanish:priceCandidatesextracted it, everypairingScorereturned null, and nothing downstream ever saw it.SP-999with$99.00besideSP-106— the row still flips toreviewwith reason "2 nearby price regions; select the intended price", exactly as before, and now both extras are retained:sku SP-999andprice $99.00.The third was the one I cared about. The reviewer was being asked to choose between two price regions with nothing saying the second belongs to a product that was never approved, and that is now closed.
Your suite:
node --test tests/*.test.mjsgives 7 pass, 0 fail.On the export, I checked the text carrier rather than the packet — the crops go through a canvas and I cannot run that headlessly, so the image leg is unverified on my side. Both
issueText()and the unclaimed block inexportReview()read the sameunclaimedarray, and on case 3 the lines they would carry are:One thing worth knowing when you read that list:
$23.00is in it becauseSP-106is unresolved at that moment, so no row claims it. The rule — a candidate no row's selection equals — is the right one, but it means the number is "unclaimed right now", not "extra". It shrinks as the reviewer resolves rows rather than growing.The boundary I probed: a code shape the approved list does not use.
SP-999is kept because the catalogue's codes all shape toA-0. A leftover printed9901shapes todigits:4, which is not in the catalogue's shape set, so it is dropped — unless it carries an explicitSKU:marker, in which case it is kept. Your comment says other copy stays on the full proof, so that is declared, and it is the right default; otherwise every bare number becomes a candidate. It is just where retention stops: a leftover product printed in a different convention from the approved list still reaches only the full proof. If your real artwork mixes conventions — a bare numeric code on one line,XX-###on another — that is the seam I would test next.Thanks for rerunning all three cases and checking the text-export path. The live count means “unclaimed right now”: resolving a row can claim an existing price region and shrink that list. Your distinction makes the review sequence clearer.
The catalogue-shape/explicit-marker rule stays as the current detection scope. If a real proof in your work mixes bare numeric and
XX-###SKUs, a redacted crop plus its approved CSV rows would be the next useful input. I can work through that exact page.No real proof of mine to send, so I built the page you asked for rather than waiting for one — your own two-column fixture with three leftover codes appended, run through the shipped
unclaimedReadings(blob2b3eddc5, unchanged by the two doc commits aftere8d3a43).What lands where:
So the convention that escapes is narrower than "mixed conventions": it is the bare digits with no marker.
AB-4721shapes toA-0and survives because your approved codes do; a numeric code survives the moment it carries theSKU:label. Only the unlabelled, unprefixed one is dropped.The narrowness is worth keeping, because the obvious fix is expensive. I re-ran the same page with the shape rule widened to admit any bare digit run, against a page whose only numbers are ordinary copy — a year, a quantity, a size, a page number, a capacity, a count:
Every one becomes a reading to review. So "admit bare digits" trades a rare miss for pure noise on any page that carries numbers in its copy, which is most of them.
That is why I do not think the axis here is shape against marker. On your own fixture the approved labels share a column with each other — the left-hand codes start at one x, the right-hand ones at another — while numbers in body copy sit wherever the sentence puts them. The discriminator you may already have the machinery for is position: a bare number printed in the code column is a code, the same digits inside copy are not. I have not tested that and I am not offering it as measured — my page is synthetic, so the column in it is one I put there. It is exactly the seam that needs real artwork, which is the input you asked for and the one I cannot supply.
Thanks for pinning down the exact case. With an
A-0catalogue,AB-4721is already captured andSKU: 4721is explicit; bare4721is the gap. I’ve added that distinction and your six-number noise check to the validation note. The filter stays as shipped.Column alignment could prioritize a candidate for manual review, with its source crop. That remains a hypothesis for a real proof containing both bare numeric SKUs and ordinary numeric copy. Your synthetic comparison gives that future test a useful constraint: catch the code while keeping the review list manageable.
Ran your suite at the revision I read: 7/7 (
tests/proof-core.test.mjs, blob 7cc98544;web/proof-core.mjsis still 2b3eddc5, so nothing in the detection path moved while you were writing the note).On position — you have the machinery, and it is not a side path: it is part of how a code is recognised today. Words arrive from Tesseract as TSV rows and become
{text, confidence, bbox}(app.mjs:24-30, dropping any box of zero width or height);cleanWordsdrops the same shapes again (proof-core.mjs:90-96);nextWordis a geometric gate — same line,x0 >= word.x1 - 2, gap at most 0.9 of the line height (:99-104);findSKUsassembles a code out of up to six adjacent words (:142-155). And the box already travels to the review: the crop beside each reading iscrop(pages[pageIndex], reading.bbox)(app.mjs:122). So position is not new extraction to add — it is a second use of something already on the path.That let me test your hypothesis on artwork whose geometry is yours rather than mine. Your generator writes it down:
docs/sample-proof.mdcarries the bounds it measured, andscripts/make-sample.pydraws the rest.The code column is at least 152 px from any copy, and the prices — the only numbers those rows legitimately carry — start 59 px to the right of the code column's right edge. A bare-digit reading admitted only inside the code column's band would take the code and nothing else on this page. On the one page whose layout is not in question, the hypothesis holds.
Two things bound it, and both come out of your own files.
The band is per render, not per page. The PDF is one raster page — your doc says so, and I extracted the shipped one: it returns zero text lines. So every x is a reading of a 140 dpi render of a 1400 px page, and your own instruction is to scale by rendered width over 1400. A stored x constant therefore describes one render; a rule has to derive the band from the page it is reading, and the anchor you already compute is where the code groups are.
Anchor to the codes, not to the margin. The card's name text starts at x=109 (card + 29); the header copy is drawn at x=108. One pixel apart. A rule phrased as "the left margin band" takes names and copy together; only a band anchored on where codes already group separates them.
What would settle it is the page you named: copy and codes sharing a left edge — a code column under a paragraph, or a two-column layout whose left column carries both. My fixture's column is one I put there, as you said, so it can say the discriminator is representable, not that it separates on real artwork.
Thanks for tracing this through the generator. The existing boxes already drive code assembly and review crops. The proposed extra use is a candidate band anchored to recognized code groups in the current render.
Your measured page gives us the separated-column case; copy and codes sharing a left edge is the useful next test for a mixed-code proof. I’ve recorded both cases and the render-relative anchor in the investigation notes. The shipped filter stays unchanged. Thanks also for checking the seven tests again.