Fix the depth-band drift; Central Towers correctly stays blocked

The merge guard that stops a priced tail row being folded into the line above
only excluded 'a)'/'b)' labels, not '.01'-style references. So '.01 0.5 - 1.0m
Deep' was swallowed by the sub-group heading above it, and the first depth line
of EVERY group never entered the candidate pool. The source line then had
nowhere sensible to go and drifted onto the next group's rows.

  1200LE before:  r37 heading, r39 '.02 1.0-2.0m', r41 '.03', r43 '.04'
                 - the '.01 0.5-1.0m' slot was absent entirely
  1200LE after:   r37 heading, r37 '.01 0.5-1.0m', r39 '.02', r41, r43

Central Towers 190 -> 198 written, 25 -> 17 unplaced. The 1200LE depth-band
misplacements are gone.

Also fixed two false positives in the placement check itself: rows whose
description cell holds a formula ('=Measurement!A18') are unverifiable rather
than mismatched, and must not cry wolf. The check has been confirmed to fail
when the bugs are re-introduced and to stay quiet on a clean build.

What remains is reported rather than hidden: 15 misplaced lines on Central
Towers, 14 carrying a value. A gap-penalty sweep from -0.20 to -1.50 moves it
not at all, so this is not score-chasing - both documents contain four
separate near-identical depth-band groups in 1200DB alone, and one global
alignment distributes them wrongly. Closing that needs anchoring on confident
matches and interpolating within blocks.

Blueberry: 103 written, 1 unplaced, 0 misplaced, not blocked.
Central Towers: 198 written, 17 unplaced, 15 misplaced, BLOCKED - the app
knows it is wrong and refuses to produce a file, which is correct.
This commit is contained in:
MEGA INFRA 2026-10-04 12:38:53 +02:00
parent 19918c9a3e
commit c8c427ae46
3 changed files with 34 additions and 1 deletions

View file

@ -419,3 +419,21 @@ wondering what the tool assumed.
Those are preserved but will not resolve without the linked file.
- Description joins can drop a space where Candy's print engine split a word
(`Fixed-ChargeItems`). Cosmetic.
### Where the placement check stands
The check is sound — silent on Blueberry, fires on Central Towers — but it has
exposed that **the alignment drift is not fixed.** Fifteen lines on Central
Towers are still placed on the wrong row, fourteen of them carrying a value.
A gap-penalty sweep from -0.20 to -1.50 moves it not at all, so this is not the
aligner chasing scores. Both documents contain **four separate near-identical
depth-band groups** in `1200DB` alone, and a single global alignment
distributes them wrongly. Closing that needs a different strategy — anchor on
the confidently matched lines and interpolate within each block, rather than
solving the whole document as one problem.
Until then **Central Towers is blocked and will not produce a file.** That is
the correct behaviour: the app now knows it is wrong and refuses, rather than
sending it.

View file

@ -371,6 +371,12 @@ class Slot:
# a description opening with a lettered sub-item label (a), b), c)) starts a
# NEW line rather than continuing the one above
LETTER_ONLY = re.compile(r"^\(?[a-z]\)\s", re.I)
# a description that OPENS with its own reference - ".01 ", "1.2 ", "c) " -
# starts a NEW line. Only bare continuation text may be folded into the row
# above. Excluding only "a)" left ".01 0.5 - 1.0m Deep" being swallowed by the
# sub-group heading above it, which is how a whole depth band went missing from
# the pool and its rate drifted onto the next group's rows.
STARTS_LINE = re.compile(r"^\(?[a-z]\)\s|^\.?\d+(?:\.\d+)*[\s.]", re.I)
# ".01", "1.2", "a)" - a description that OPENS with a reference starts a new
# line; only bare continuation text may be folded into the one above
STARTS_LINE = re.compile(r"^\(?[a-z]\)\s|^\.?\d+(?:\.\d+)*[\s.]", re.I)
@ -426,7 +432,7 @@ def read_slots(ws, lay: Layout) -> list[Slot]:
# A description opening "a)" / "b)" / "c)" is a NEW line and must never
# be absorbed, or sub-items get folded into the heading above them.
if (out and priced and not raw and desc and out[-1]._bare
and not LETTER_ONLY.match(desc)):
and not STARTS_LINE.match(desc)):
prev = out[-1]
prev.desc = f"{prev.desc} {desc}".strip() if prev.desc else desc
prev.row = r

View file

@ -180,6 +180,15 @@ def _placement_report(result, out) -> list:
row_desc = _norm_txt(_row_desc(out[sheet], row, lay))
want = _norm_txt(want)
# A cell holding a formula is not the row's identity - these templates
# pull a description straight out of a Measurement sheet
# ("=Measurement!A18"). The row may be perfectly correct; we simply
# cannot read it, so say nothing rather than cry mismatch.
# any formula inside the text means this row pulls its description from
# elsewhere (these templates use "=Measurement!A18"); it may be the
# right row and simply not readable here
if "=" in row_desc:
continue
if not row_desc:
bad.append((sheet, row, want[:44], "that row carries no text at all"))
continue