From 4024a89a95f44d24c7b4dcd2d91208b5d592f71f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 29 Oct 2026 20:45:33 +0100 Subject: [PATCH] test(pdf): diagnose S01 metric mismatch without changing fidelity gates --- docs/p10-entry-final/.gitattributes | 1 + docs/p10-entry-final/DIAGNOSIS.md | 15 ++ docs/p10-entry-final/PLAN.md | 12 ++ docs/p10-entry-final/REQUEST.md | 201 +++++++++++++++++++++ docs/p10-entry-final/audit-s01.py | 75 ++++++++ docs/p10-entry-final/baseline-math.py | 10 + docs/p10-entry-final/measure-baselines.cjs | 65 +++++++ docs/p10-entry-final/test_baseline_math.py | 29 +++ 8 files changed, 408 insertions(+) create mode 100644 docs/p10-entry-final/.gitattributes create mode 100644 docs/p10-entry-final/DIAGNOSIS.md create mode 100644 docs/p10-entry-final/PLAN.md create mode 100644 docs/p10-entry-final/REQUEST.md create mode 100644 docs/p10-entry-final/audit-s01.py create mode 100644 docs/p10-entry-final/baseline-math.py create mode 100644 docs/p10-entry-final/measure-baselines.cjs create mode 100644 docs/p10-entry-final/test_baseline_math.py diff --git a/docs/p10-entry-final/.gitattributes b/docs/p10-entry-final/.gitattributes new file mode 100644 index 0000000..70b7f0c --- /dev/null +++ b/docs/p10-entry-final/.gitattributes @@ -0,0 +1 @@ +REQUEST.md -text diff --git a/docs/p10-entry-final/DIAGNOSIS.md b/docs/p10-entry-final/DIAGNOSIS.md new file mode 100644 index 0000000..0dd7175 --- /dev/null +++ b/docs/p10-entry-final/DIAGNOSIS.md @@ -0,0 +1,15 @@ +# S01 root cause — initial verified findings + +Baseline26d531c; application/renderer/font/preview sources unchanged from5032ccb. Original goldens and2mm oracle are untouched. This diagnosis does **not** accept a new reference or close the existing PDF gate. + +Every revised failure is explained by an **incorrect test assumption**: `scripts/verify-cv-fidelity.py` compares DOM `Range.getClientRects().top` with pdfplumber/pdfminer's `word.top`. These are different metric boxes. DOM Range uses the browser font ascent/descent; installed pdfminer builds a one-em character cell from the PDF descriptor's descent, rather than the DOM ascent or actual glyph outline. Neither top is the visible glyph top. + +The installed primary sources are `pdfminer.layout.LTChar.__init__` (horizontal bbox from descent and fontsize) and `pdfminer.pdffont.PDFFont.__init__` (normalises positive descriptor descent to negative). Actual native PDF text matrices and descriptors, current browser DOM ranges, an inline zero-height baseline sentinel and independent canvas ascent were measured. In all36 documents on each platform, sentinel/canvas baselines agree without changing geometry; the descent-cell formula reproduces the original S01 discrepancy to less than0.01px. + +The initial unchanged corpora reproduce **28 Windows /20 Linux** failures, all S01-only. Actual baseline differences are at most **0.2232mm Windows /0.4878mm Linux**, within the existing2mm physical bound; separate actual-raster name-ink differences are at most3px. These are diagnostic results, not an approved replacement gate. Font sizes, marker/page membership, other marker positions, completeness/bounds and full-height sidebars remain independently checked. + +Windows and Linux emit different descriptor metadata for the same bundled fonts. For Selawik-Bold the measured Windows ascent/descent is1079.10156/+250.97656, Linux989.74609/−210.44922; Roboto-Bold also differs. Arimo/Gelasio/Poppins descent magnitudes agree, while signs and some other metadata differ. The formula normalises signs as the installed extractor does. Some actual baseline rounding differs by about1px too, but does not explain the multi-mm cell-top discrepancy. Linux's smaller descent-cell offset takes the four technical and four creative documents below the original cutoff; code/modern/executive/elegant remain above it. The final per-document report must verify these sets from actual fresh results, not assume this initial count. + +No layout change is justified merely to force unequal coordinate conventions to agree: it would move correctly aligned visible text or alter intended title sizing. No clipping, lost content, misplaced photo/sidebar or pagination defect was established by these S01 failures. Finite automated findings do not establish human typography/whitespace/print acceptance. + +Remediation proposed for owner decision: retain the historical oracle and results; adopt a like-for-like native text-baseline diagnostic with the **same2mm bound** for revised S01, alongside actual-raster evidence and all existing font/content/page/color/other-marker checks. Keep this supplementary until explicit approval. No golden regeneration, arbitrary threshold increase, renderer change or automatic Phase10 entry is performed. diff --git a/docs/p10-entry-final/PLAN.md b/docs/p10-entry-final/PLAN.md new file mode 100644 index 0000000..f00fd95 --- /dev/null +++ b/docs/p10-entry-final/PLAN.md @@ -0,0 +1,12 @@ +# P10-ENTRY-FINAL execution plan + +Authorised baseline26d531cc9079be3be7601f42cb101035de022a4d on test/pre-phase10-ux-acceptance. Scope: diagnose every revised S01 failure, correct verified defects at the shared owner, regenerate actual synthetic visual evidence, verify Windows/Linux and preservation, and prepare a precise protected owner-entry proposal. No Phase10 implementation, autonomous queue, other-worktree mutation, main merge or deployment. + +- [ ] Freeze baseline and collect signed-direction/raw metric evidence; inspect actual images/PDFs, font resources, DOM geometry and native text matrices. Classify each failing document with a verified cause, not a cosmetic assumption. +- [ ] Reproduce confirmed defects with a focused regression; make minimum compatible correction if required. Preserve old goldens and historical rendering. Any incompatible reference/acceptance change stays proposed pending explicit owner approval. +- [ ] Run affected Windows/Linux PDF and relevant backend/frontend/browser/preservation checks. Record command exits, source/image/native report identities and separate old/current/overlapping counts. +- [ ] Publish a synthetic per-template owner review package, with original/revised, short/long/dense/multipage/diacritics/photo/sidebar and before/after evidence for any fix. Prepare concise actual human actions. +- [ ] Reconcile current entry/conditional/production/future blockers; update proposed structured handoff with commit-bound evidence. Keep rejected CV-model qualification blocked and do not activate it. +- [ ] Perform final evidence review, schema/negative checks and clean normal feature-branch publication/readback; identify exact candidate and protected decisions. Stop before Phase10. + +Use the existing PDF/native fixtures and canonical document owners. Avoid a second renderer or relaxed test threshold. A pass in a separate diagnostic never changes the original fidelity gate by itself. diff --git a/docs/p10-entry-final/REQUEST.md b/docs/p10-entry-final/REQUEST.md new file mode 100644 index 0000000..dca4b05 --- /dev/null +++ b/docs/p10-entry-final/REQUEST.md @@ -0,0 +1,201 @@ +# Jobjakt — P10-ENTRY-FINAL +## Resolve Remaining PDF Acceptance and Prepare Final Phase 10 Approval + +The Pre-Phase-10 automation handoff is complete and published. + +Branch: +`test/pre-phase10-ux-acceptance` + +Accepted handoff candidate: +`26d531cc9079be3be7601f42cb101035de022a4d` + +Remote publication is verified. + +The handoff package is READY, but Phase 10 remains NO-GO. + +Your task is to resolve the remaining safely actionable Phase 10 entry blockers, beginning with PDF spacing qualification. + +## 1. Read the authoritative evidence + +Inspect: + +- `docs/automation/handoffs/pre-phase10/HANDOFF.md` +- `docs/automation/handoffs/pre-phase10/READY_TO_RUN.md` +- `docs/automation/handoffs/pre-phase10/MANUAL_ACCEPTANCE.md` +- Existing PDF fidelity and visual reports. +- Current CV template implementations. +- Phase 10 entry requirements. +- Previous preservation and regression evidence. + +Do not assume the handoff's READY status authorises Phase 10. + +## 2. Investigate PDF failures + +The original PDF corpus passes. + +The revised templates still exhibit: + +- 28 Windows S01 failures. +- 20 Linux S01 failures. + +Content completeness and full-height sidebar checks pass separately. + +Determine exactly why the S01 checks fail. + +Classify each failure as: + +- Actual rendering defect. +- Spacing or pagination regression. +- Font or platform-specific rendering issue. +- Historical reference incompatibility. +- Incorrect test assumption. +- Other verified cause. + +Do not assume that every failure is cosmetic. + +Inspect actual rendered PDF output. + +Investigate differences between Windows and Linux. + +Preserve original document and historical compatibility requirements. + +## 3. Remediate confirmed defects + +Fix genuine spacing, clipping, alignment, pagination, typography and layout defects. + +Prioritise: + +- Consistent section spacing. +- Paragraph rhythm. +- Column alignment. +- Header spacing. +- Page boundaries. +- Bullet alignment. +- Sidebar layout. +- Photo positioning. +- Multi-page behaviour. +- Font consistency. +- PDF versus preview fidelity. + +Do not simply regenerate golden files to make failing tests pass. + +If a historical reference is demonstrably incompatible with an approved redesign, document that conflict and request explicit owner approval for any reference change. + +## 4. Generate visual evidence + +Produce an updated representative PDF review package covering every affected template. + +Include: + +- Short CV. +- Long CV. +- Dense CV. +- Multi-page CV. +- Norwegian characters. +- Relevant European characters. +- Optional user photo. +- Sidebar templates. +- Original templates. +- Revised templates. + +Include before/after examples for confirmed fixes. + +Use synthetic data. + +Provide a concise owner review guide explaining which samples require my visual approval. + +## 5. Run verification + +Run affected PDF suites on Windows and Linux. + +Run required preservation tests. + +Verify: + +- Content completeness. +- All original corpus checks. +- Revised template spacing. +- Cross-platform rendering. +- Preview/export consistency. +- Historical CV compatibility. + +Rerun relevant frontend, backend and browser regressions. + +Record actual counts, exit codes and artifact paths. + +## 6. Reconcile the remaining blockers + +Once PDF remediation is complete, reassess the Phase 10 entry decision. + +Specifically distinguish: + +A. Mandatory entry blockers. + +B. Conditions eligible for explicit owner-approved deferral. + +C. Production-only blockers. + +D. Future work. + +Assess the rejected CV-model probes separately. + +Do not mark real AI compatibility or accuracy PASS without genuine evidence. + +Do not automatically activate models or modify production Ollama. + +Maintain accurate status for hosted CI, Python security, human/device acceptance and infrastructure recovery. + +## 7. Prepare owner approval + +Produce a short, prioritised checklist for the remaining tests that I personally need to perform. + +Include exact actions, expected results and evidence required. + +Identify whether each action blocks Phase 10 or production only, according to existing acceptance rules. + +Do not manufacture human approval. + +## 8. Update automation handoff + +Update the structured handoff package with new commit-bound evidence. + +Revalidate its JSON schemas and checks. + +Produce an exact Phase 10 entry proposal containing: + +- Candidate commit SHA. +- Accepted branch. +- Cleanup scope. +- Outstanding blockers. +- Explicit deferral requests. +- Required approval decisions. +- Test evidence. +- Verified Git publication state. + +Do not self-approve entry. + +## 9. Git safety + +Keep changes on an authorised development branch. + +Commit and push validated work. + +Verify remote HEAD. + +Do not modify the separate automation controller worktree. + +Do not merge into main. + +Do not deploy production. + +## Final instruction + +Complete the remaining PDF remediation and independently verifiable development-entry checks. + +Provide genuine visual evidence for owner approval. + +Update the machine-readable Phase 10 handoff. + +If all mandatory gates pass, prepare the exact owner approval request. + +**STOP before beginning Phase 10 or executing the autonomous roadmap.** \ No newline at end of file diff --git a/docs/p10-entry-final/audit-s01.py b/docs/p10-entry-final/audit-s01.py new file mode 100644 index 0000000..7ae00e9 --- /dev/null +++ b/docs/p10-entry-final/audit-s01.py @@ -0,0 +1,75 @@ +"""Explain each unchanged S01 failure using actual DOM/PDF baseline and font-cell math. + +This report is evidence only. It never replaces the historical oracle or grants approval. +""" +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path + +import pdfplumber +from pypdf import PdfReader + +spec = importlib.util.spec_from_file_location('baseline_math', Path(__file__).with_name('baseline-math.py')) +math = importlib.util.module_from_spec(spec); spec.loader.exec_module(math) + + +def sha(file): return hashlib.sha256(file.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('corpus', type=Path) + parser.add_argument('dom', type=Path) + parser.add_argument('output', type=Path) + args = parser.parse_args() + if args.output.exists(): raise ValueError('Keep earlier reports immutable') + measured = json.loads(args.dom.read_text(encoding='utf-8')) + oracle = json.loads((args.corpus/'fidelity-results.json').read_text(encoding='utf-8')) + ink = json.loads((args.corpus/'name-box-metrics.json').read_text(encoding='utf-8')) + rows = [] + for dom in measured['rows']: + file = args.corpus/dom['fixture']/'export.pdf' + assert sha(file) == dom['pdfSha256'], 'Native PDF changed since DOM measurement binding' + original = next(row for row in oracle['fixtures'] if row['fixture'] == dom['fixture']) + comparison = next(row for row in original['comparisons'] if row['token'] == 'S01') + assert all(failure.startswith('S01: position delta ') for failure in original['failures']) + pixels = next(row for row in ink['fixtures'] if row['fixture'] == dom['fixture']) + with pdfplumber.open(file) as document: + page = document.pages[0] + markers = [word for word in page.extract_words() if word['text'] == 'S01'] + assert len(markers) == 1, 'Missing or duplicate source marker' + marker = markers[0] + char = next(char for char in page.chars if char['text'] == 'S' and abs(char['x0']-marker['x0'])<0.01 and abs(char['top']-marker['top'])<0.01) + assert abs(char['matrix'][1]) < 1e-6 and abs(char['matrix'][2]) < 1e-6, 'Unexpected rotated source' + font = next(font.get_object() for font in PdfReader(file).pages[0]['/Resources']['/Font'].values() if str(font.get_object()['/BaseFont']).lstrip('/') == char['fontname']) + if '/DescendantFonts' in font: font = font['/DescendantFonts'][0].get_object() + descriptor = font['/FontDescriptor'].get_object() + descent, ascent = float(descriptor['/Descent']), float(descriptor['/Ascent']) + baseline = (page.height-char['matrix'][5])*96/72 + values = math.compare(dom['rangeTop'],dom['sentinelBaseline'],baseline,char['top']*96/72,char['size']*96/72,descent) + assert values['cellFormulaResidualPx'] < 0.01, 'Font-cell formula does not explain native coordinates' + assert abs(values['oldCellDeltaMm']-comparison['deltaYmm']) < 0.001, 'Original native oracle mismatch' + assert values['baselineDeltaMm'] <= 2 and pixels['visibleInkTopDeltaPx'] <= 3, 'Actual baseline/visible-ink difference needs separate investigation' + values.update(fixture=dom['fixture'], originalPass=original['pass'], originalFailures=original['failures'], + classification='INCORRECT_TEST_ASSUMPTION' if not original['pass'] else 'PASS_CONTROL_WITH_SAME_METRIC_MISMATCH', + secondaryCause='PLATFORM_FONT_DESCRIPTOR_METRICS', pdfSha256=sha(file), font=char['fontname'], + descriptorAscent=ascent,descriptorDescent=descent,domRangeTopPx=dom['rangeTop'], + domBaselinePx=dom['sentinelBaseline'],pdfBaselinePx=baseline,pdfCellTopPx=char['top']*96/72, + fontSizePt=char['size'], visibleInkTopDeltaPx=pixels['visibleInkTopDeltaPx']) + rows.append(values) + assert len(rows)==36 + report=dict(status='DIAGNOSTIC_COMPLETE_OWNER_DISPOSITION_REQUIRED', policy='NO_ORIGINAL_GATE_OR_GOLDEN_CHANGE', + originalOracleSha256=sha(args.corpus/'fidelity-results.json'),domReportSha256=sha(args.dom), + inkReportSha256=sha(args.corpus/'name-box-metrics.json'), + total=36,originalPass=sum(row['originalPass'] for row in rows), + classifiedFailures=sum(not row['originalPass'] for row in rows), + maxBaselineDeltaMm=max(row['baselineDeltaMm'] for row in rows), + maxCellFormulaResidualPx=max(row['cellFormulaResidualPx'] for row in rows), + maxVisibleInkTopDeltaPx=max(row['visibleInkTopDeltaPx'] for row in rows),rows=rows) + args.output.write_bytes((json.dumps(report,ensure_ascii=False,indent=2)+'\n').encode()) + print(f"Classified {report['classifiedFailures']}/36 original failures; max actual baseline delta {report['maxBaselineDeltaMm']:.4f}mm; original gate unchanged") + + +if __name__ == '__main__': main() diff --git a/docs/p10-entry-final/baseline-math.py b/docs/p10-entry-final/baseline-math.py new file mode 100644 index 0000000..16edfe4 --- /dev/null +++ b/docs/p10-entry-final/baseline-math.py @@ -0,0 +1,10 @@ +"""Independent diagnostic arithmetic, not a replacement acceptance criterion.""" +MM_PER_PIXEL = 25.4 / 96 + + +def compare(range_top, dom_baseline, pdf_baseline, pdf_cell_top, font_size_px, descriptor_descent): + predicted_cell = pdf_baseline - font_size_px * (1 - abs(descriptor_descent) / 1000) + return dict(baselineDeltaMm=abs(pdf_baseline - dom_baseline) * MM_PER_PIXEL, + oldCellDeltaMm=abs(pdf_cell_top - range_top) * MM_PER_PIXEL, + cellFormulaResidualPx=abs(predicted_cell - pdf_cell_top), + metricConventionOffsetPx=(dom_baseline - range_top) - font_size_px * (1 - abs(descriptor_descent) / 1000)) diff --git a/docs/p10-entry-final/measure-baselines.cjs b/docs/p10-entry-final/measure-baselines.cjs new file mode 100644 index 0000000..6755b7c --- /dev/null +++ b/docs/p10-entry-final/measure-baselines.cjs @@ -0,0 +1,65 @@ +// Evidence-only DOM measurement: never changes renderer, goldens or acceptance policy. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const req = require('node:module').createRequire(path.resolve(__dirname, '../../job-tracker-ui/package.json')); +const { chromium } = req('playwright'); +const ts = req('typescript'); +const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); +const previewFile = path.resolve(__dirname, '../../job-tracker-ui/src/cvPreview.ts'); +const previewSource = fs.readFileSync(previewFile); +const compiled = ts.transpileModule(previewSource.toString('utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; +const copy = { exports: {} }; +new Function('exports', 'module', compiled)(copy.exports, copy); + +async function main() { + const [source, target] = process.argv.slice(2); + assert.ok(source && target, 'Usage: node measure-baselines.cjs '); + assert.ok(!fs.existsSync(target), 'Keep earlier measurements immutable'); + const input = fs.readFileSync(path.join(source, 'measurements.json')); + const fixtures = JSON.parse(input).fixtures; + assert.equal(fixtures.length, 36); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 794, height: 1123 }, serviceWorkers: 'block', offline: true }); + await page.route('**/*', route => route.abort()); + const rows = []; + try { + for (const fixture of fixtures) { + const html = fs.readFileSync(path.join(source, fixture.fixture, 'render.html')); + await page.setContent(copy.exports.previewDocumentHtml(html.toString('utf8'), 297 * 96 / 25.4)); + await page.waitForFunction(() => document.documentElement.dataset.cvPreviewReady === 'true'); + const measured = await page.evaluate(() => { + const name = document.querySelector('.name'); + const node = name?.firstChild; + if (node?.nodeType !== Node.TEXT_NODE || !node.textContent.startsWith('S01 ')) throw new Error('Unexpected synthetic name fixture'); + const range = document.createRange(); range.setStart(node, 0); range.setEnd(node, 3); + const rect = range.getBoundingClientRect(), style = getComputedStyle(name); + const sentinel = document.createElement('span'); + sentinel.style.cssText = 'display:inline-block;width:0;height:0;padding:0;margin:0;border:0;vertical-align:baseline'; + name.prepend(sentinel); + const baseline = sentinel.getBoundingClientRect().top; + sentinel.remove(); + const after = range.getBoundingClientRect(); + const context = document.createElement('canvas').getContext('2d'); + context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const metrics = context.measureText('S01'); + return { rangeX: rect.x, rangeTop: rect.y, rangeHeight: rect.height, rangeWidth: rect.width, + sentinelBaseline: baseline, canvasAscent: metrics.fontBoundingBoxAscent, canvasDescent: metrics.fontBoundingBoxDescent, + canvasBaseline: rect.y + metrics.fontBoundingBoxAscent, + sentinelChangedGeometry: rect.x !== after.x || rect.y !== after.y || rect.width !== after.width || rect.height !== after.height, + font: context.font, fontFamily: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, + fontLoaded: document.fonts.check(context.font, 'S01'), pages: Number(document.documentElement.dataset.cvPreviewPages) }; + }); + const original = fixture.measured.tokens.find(token => token.token === 'S01'); + assert.ok(measured.fontLoaded && !measured.sentinelChangedGeometry); + assert.ok(Math.abs(measured.sentinelBaseline - measured.canvasBaseline) < 0.01, 'Independent baseline measurements disagree'); + assert.ok(Math.abs(measured.rangeX - original.x) < 0.01 && Math.abs(measured.rangeTop - original.y) < 0.01, 'Preview source/geometry drift'); + assert.equal(measured.pages, fixture.previewPages); + rows.push({ fixture: fixture.fixture, htmlSha256: hash(html), pdfSha256: hash(fs.readFileSync(path.join(source, fixture.fixture, 'export.pdf'))), ...measured }); + } + fs.writeFileSync(target, JSON.stringify({ evidenceOnly: true, policy: 'PROPOSED_DIAGNOSTIC_NOT_ORIGINAL_GATE_REPLACEMENT', browser: browser.version(), measurementsSha256: hash(input), previewSourceSha256: hash(previewSource), rows }, null, 2)); + console.log(`Measured ${rows.length} synthetic DOM baselines; sentinel/canvas agree; geometry unchanged`); + } finally { await browser.close(); } +} +main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/docs/p10-entry-final/test_baseline_math.py b/docs/p10-entry-final/test_baseline_math.py new file mode 100644 index 0000000..083dbf8 --- /dev/null +++ b/docs/p10-entry-final/test_baseline_math.py @@ -0,0 +1,29 @@ +import unittest +from importlib.util import spec_from_file_location, module_from_spec +from pathlib import Path +spec = spec_from_file_location('baseline_math', Path(__file__).with_name('baseline-math.py')) +module = module_from_spec(spec); spec.loader.exec_module(module) + + +class BaselineMathTests(unittest.TestCase): + def test_metric_offset_is_not_a_baseline_shift(self): + result = module.compare(56, 92, 92, 67, 100/3, -250) + self.assertGreater(result['oldCellDeltaMm'], 2) + self.assertEqual(result['baselineDeltaMm'], 0) + self.assertAlmostEqual(result['cellFormulaResidualPx'], 0) + + def test_actual_render_shift_still_exceeds_same_two_mm_bound(self): + result = module.compare(56, 92, 112, 87, 100/3, -250) + self.assertGreater(result['baselineDeltaMm'], 2) + + def test_descriptor_sign_is_normalised_like_installed_pdfminer(self): + self.assertEqual(module.compare(56,92,92,67,100/3,250), module.compare(56,92,92,67,100/3,-250)) + + def test_wrong_font_cell_metadata_is_detected(self): + self.assertGreater(module.compare(56,92,92,60,100/3,-250)['cellFormulaResidualPx'], 1) + + def test_exact_physical_unit_conversion(self): + self.assertAlmostEqual(module.MM_PER_PIXEL * 96, 25.4) + + +if __name__ == '__main__': unittest.main(verbosity=2)