Skip to content

fix(heuristics): coursework section + multiline-bullet + column deinterleave - #165

Merged
s-annam merged 5 commits into
mainfrom
epic-163-162-164-coursework-bullets
Jun 24, 2026
Merged

fix(heuristics): coursework section + multiline-bullet + column deinterleave#165
s-annam merged 5 commits into
mainfrom
epic-163-162-164-coursework-bullets

Conversation

@s-annam

@s-annam s-annam commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the full coursework/multiline-bullet parse path on multi-column student résumés (reproducer: google-docs-skia-proxy-multiline-bullets-coursework.pdf). Three ordered fixes that compose end-to-end:

Pipeline order is load-bearing: #164 de-interleaves columns → #163's boundary captures the block as education#162 folds each column's wrap onto its true parent.

Pre-existing Docent tri-line-header mis-parse (bullets in "Other") is orthogonal and left untouched.

Closes #163
Closes #162
Closes #164

Test plan

  • npm run typecheck clean
  • npm run test green (756 passed, 54 files)
  • npm run lint (eslint) clean
  • Corpus snapshots regenerated where scoring legitimately shifted (junk coursework bullets removed; specificity/structure rise); no section/field/count loss — diffs eyeballed

…erleave

- "Relevant Coursework"/"Coursework" now opens an education-type section
  boundary (added aliases + anchor); isVisualHeader gains an ALL-CAPS
  text-pattern fallback independent of font metadata; VISUAL_HEADER_FONT_RATIO
  lowered 1.2→1.15 — stops unknown headers swallowing the prior entry's
  description (#163)
- add mergeWrappedContinuations() in entry-blocks.ts, run per-section in
  toSectionedResume before byName flatten; folds wrapped-bullet continuation
  lines onto their parent so bullet pool and reconstructed-role description
  agree — no more truncation or "Other" mis-grouping (#162)
- add reorderEmbeddedColumns pass upstream of line grouping; de-interleaves
  localized multi-column blocks (e.g. coursework grid) to column-major
  reading order before sectionizing (#164)

Resolves #163
Resolves #162
Resolves #164
Comment thread src/lib/heuristics/sections.ts Fixed
@s-annam
s-annam requested a review from Vaishnavi1709 June 24, 2026 19:18
Extract groupItemsIntoRows + reorderColumnRun helpers so the main loop
drops below the fallow cognitive-complexity threshold (was 27, limit 15).
Behavior-preserving — same column-major de-interleave, 756 tests green.

Refs #164

@Vaishnavi1709 Vaishnavi1709 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice composition — the three sub-fixes (#163/#162/#164) read cleanly in the order claimed, and the helper extraction (groupItemsIntoRows, reorderColumnRun) resolves the fallow complexity flag without changing behavior. Tests are well-rationalized; the "does NOT promote Title-Case job title" pin on isTextPatternHeader and the single-row date-rail pin on reorderEmbeddedColumns are exactly the right invariants to lock down.

One must-address before merge, plus one thing worth a sanity check.

🔴 Must address — cascade regression on student-projects-activities-singlecol.expected.json

That snapshot delta isn't only a score uplift — it flips:

cascade.confidence:           0.88 → 0
cascade.suggestedEscalation:  "none" → "ocr"

…which contradicts the PR body's "no section/field/count loss." Reproduced on both branches:

branch rawChars extractedChars ratio confidence escalation
main 2279 1288 0.565 0.88 "none"
this PR 2279 1086 0.476 0 "ocr"

Root cause: the bleed you correctly removed used to count toward experience.description (its char sum drops 819 → 617 — exactly the 202-char delta). countExtractedChars (cascade.ts:497) only sums typed fields, so the now-correctly-terminated coursework block — which lands in sections.byName rather than a typed schema field — isn't counted. The extracted/raw ratio drops below EXTRACTION_RATIO_FLOOR = 0.5, collectHardFailures raises low_extraction_ratio, and chooseEscalation routes to "ocr" (confidence.ts:78–82, :148–153, :292).

User-visible score got better (58 → 72), but the cascade now reports this resume as failed and would escalate to OCR — the opposite of the intent.

The other shifted fixtures (weasyprint-cairo-two-column, two-column-achievements-sidebar, awesome-cv-{cv,resume}) all keep their pre-PR cascade.confidence, so the regression is isolated. But the root cause is general: every future "correctly terminated unknown section" you ship has the same risk of dragging the extraction ratio toward the floor.

Pick one:

  1. Recommended: extend countExtractedChars to also tally chars from sections.byName beyond the typed-field set — small, scoped, no API change, and protects future fixes too.
  2. Have the coursework block populate education[] (e.g. a comma-joined course list as degree) so its content lands in an already-counted field.
  3. Acknowledge the regression in the PR body + a comment on the fixture and open a follow-up issue — only if you'd rather not expand scope here.

🟡 Worth a quick look — awesome-cv structure score declined

awesome-cv-cv:     structure 20 → 18, goodBullets 35 → 32 (totalBullets 52)
awesome-cv-resume: structure 23 → 20, goodBullets 23 → 20 (totalBullets 30)

Same total bullets, fewer "good" ones. Specificity ↑ (metricBullets ↑) is expected — wrap-merge revealing metrics in the tail. But the goodBullets drop suggests merged longer bullets are tripping a length cap. Can you confirm this is the expected trade (longer merged bullets correctly reclassify as too-verbose) rather than a downstream rule that should be retuned? Not blocking.

Nits (non-blocking)

  • sections.tsentry-blocks.ts is now a type-only circular import; runtime-safe today, but a value import on the entry-blocks side would make it real. Colocating mergeWrappedContinuations next to toSectionedResume in sections.ts (its only non-test caller) would preempt this.
  • isVisualHeader's split && reads oddly — an early return on the font path would mirror the text-pattern path.
  • mergeWrappedContinuations does `${prev.text} ${line.text.trim()}` without trimming prev.text.trimEnd() on prev would avoid double-space on the off chance a line has a trailing space.

Verdict: request changes on the cascade regression; everything else is approve-modulo-nits.

Review fix for PR #165: countExtractedChars only summed typed parsed
fields, so a correctly-terminated unknown section (coursework #164) that
lands in sections.byName went uncounted. The extracted/raw ratio fell
below EXTRACTION_RATIO_FLOOR, raising low_extraction_ratio and routing to
OCR with confidence 0 — the opposite of the fix's intent.

Tally byName entries outside the typed-field set (projects,
certifications, achievements, other). Restores student-projects to
0.88/none and clears four pre-existing false-OCR escalations
(additional-skills, multiline-bullets-coursework, deedy x2) that were
already 0/ocr on main. Score blocks unchanged — extractedChars feeds
confidence/escalation only.

Also trimEnd prev.text in mergeWrappedContinuations to avoid a mid-string
double space (review nit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcsLGiQZxtq9VpbjiVzGb4
@s-annam

s-annam commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @Vaishnavi1709 — all addressed in 4717423.

🔴 Cascade regression — fixed (option 1)

Extended countExtractedChars (cascade.ts) to also tally sections.byName entries outside the typed-field set (projects/certifications/achievements/other), so a correctly-terminated unknown section no longer drags the extracted/raw ratio toward EXTRACTION_RATIO_FLOOR. Your recommended option — small, scoped, protects future fixes.

student-projects-activities-singlecol is restored to exactly the pre-PR cascade state:

confidence escalation
main 0.88 none
this PR (before) 0 ocr
this PR (after fix) 0.88 none

Your "root cause is general" call was right — four other fixtures were already 0/ocr on main for the same reason, and the fix clears all of them too:

  • google-docs-skia-proxy-multiline-bullets-coursework: 0/ocr → 0.87/none
  • google-docs-skia-proxy-additional-skills: 0/ocr → 0.74/llm
  • deedy-resume-macfonts / deedy-resume-openfonts: 0/ocr → 0.64/ner

Score blocks are unchanged across all five — extractedChars feeds confidence/escalation, not the score dimensions. Snapshots rebaked; npm run typecheck + npm run test (756) green.

🟡 awesome-cv goodBullets — confirmed expected

These fixtures aren't in the changed set, so behavior is identical to before this comment. The drop is the wrap-merge trade you predicted: merged longer bullets correctly reclassify against the length cap (verbosity), while specificity rises as the merge reveals tail metrics. Not a downstream rule that needs retuning.

Nits

  • mergeWrappedContinuations double-space — fixed: now ${prev.text.trimEnd()} ${line.text.trim()}.
  • isVisualHeader && — it already early-returns on the font path (if (isHeaderShort && font≥ratio) return true), so I left it; the only oddness is the wrapped condition, which is cosmetic.
  • sections.tsentry-blocks.ts type-only circular import — deferred. Runtime-safe today (type-only), and colocating mergeWrappedContinuations into sections.ts is a structural move I'd rather not fold into this fix PR. Worth a follow-up if we want to harden it.

Re-requesting review.

@s-annam
s-annam requested a review from Vaishnavi1709 June 24, 2026 19:54
Comment thread src/lib/heuristics/cascade.ts Fixed
The byName tally pushed countExtractedChars over fallow's thresholds
(cyclomatic 21/20, cognitive 22/15). Extract scalarFieldChars,
listFieldChars, and untypedSectionChars; the host is now a three-term
sum. Behavior unchanged — 756 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcsLGiQZxtq9VpbjiVzGb4
…sework-bullets

# Conflicts:
#	src/lib/heuristics/entry-blocks.test.ts
#	tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json
@s-annam
s-annam merged commit 35cfd37 into main Jun 24, 2026
2 checks passed
@s-annam
s-annam deleted the epic-163-162-164-coursework-bullets branch June 24, 2026 20:21
s-annam added a commit that referenced this pull request Jun 25, 2026
…erleave (#165)

Closes #162, #163, #164. Coursework/unknown-section termination, wrapped multi-line bullet folding, and embedded-column de-interleave. Untyped sections now count toward the extraction ratio so correctly-terminated unknown sections no longer trip a false OCR escalation. Merged main (#166 wrapped-header fold) — conflicts resolved, snapshot rebaked, 758 tests green.
s-annam added a commit that referenced this pull request Jun 28, 2026
…erleave (#165)

Closes #162, #163, #164. Coursework/unknown-section termination, wrapped multi-line bullet folding, and embedded-column de-interleave. Untyped sections now count toward the extraction ratio so correctly-terminated unknown sections no longer trip a false OCR escalation. Merged main (#166 wrapped-header fold) — conflicts resolved, snapshot rebaked, 758 tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants