From c8c427ae468a002c0f022e5cd8ed929736d981e5 Mon Sep 17 00:00:00 2001 From: MEGA INFRA Date: Sun, 4 Oct 2026 12:38:53 +0200 Subject: [PATCH] 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. --- README.md | 18 ++++++++++++++++++ boq_engine/template.py | 8 +++++++- boq_engine/verify.py | 9 +++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c476ea9..31b8c38 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/boq_engine/template.py b/boq_engine/template.py index c690a97..c6f4316 100644 --- a/boq_engine/template.py +++ b/boq_engine/template.py @@ -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 diff --git a/boq_engine/verify.py b/boq_engine/verify.py index 0e7adaf..cd31320 100644 --- a/boq_engine/verify.py +++ b/boq_engine/verify.py @@ -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