Skip to content

feat: section-recognition L2/L3 — anchor fallback + visual bounds - #115

Merged
s-annam merged 3 commits into
mainfrom
section-recognition-l2-l3
Jun 17, 2026
Merged

feat: section-recognition L2/L3 — anchor fallback + visual bounds#115
s-annam merged 3 commits into
mainfrom
section-recognition-l2-l3

Conversation

@s-annam

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

Copy link
Copy Markdown
Contributor

Summary

Lands L2 + L3 of the section-recognition epic (#109, on top of merged L1 #110), plus the #113-review follow-up #114.

  • Drift-guard comment in sections.config.ts overstates what it catches #114 — reword the sections.config.ts _drift guard comment to its true one-directional nature (TS catches missing SectionName keys; extra JSON keys slip through structurally) + add a runtime validate() extra-key check.
  • L2 — Head-noun anchor fallback for qualified section headers (closes #108) #111 (L2) — head-noun anchor fallback in matchSectionHeader: a qualified header ("Relevant Experience", "Customer Service Experience") matches when its last token is a section anchor, behind 7 FP guardrails (length/word-count/head-noun-last/no-terminal-punct/not-bullet/anchorFallback flag/header-casing). Exports SECTION_ANCHORS + SECTION_ANCHOR_FALLBACKS; skills/other stay off the fallback so a flattened two-column sidebar label can't strand experience. Synthetic Chromium fixture added.
  • L3 — Visual-primary boundary detection + name/contact disambiguation #112 (L3) — visual-primary boundary in splitIntoSections: a font-distinct line (ratio ≥ 1.2 over body baseline) opens a boundary even with no keyword, with name/contact disambiguation so the candidate name isn't promoted. First direct unit coverage for splitIntoSections (sections.test.ts). allCaps dropped as a visual signal after a corpus pass showed it was a net FP source (acronyms / skill tokens / sidebar labels); genuine all-caps headers are already caught by the keyword/anchor path.

Closes #108
Closes #111
Closes #112
Closes #114

Test plan

  • npm run typecheck clean
  • npm run lint (eslint) clean
  • npm run test green — 438/438
  • Fixture persona verified synthetic — no real PII (Step 3.5)
  • Manually verified in npm run dev / npm run preview

Three issues from the section-recognition epic (parent #109) land together:

#114 — sections.config.ts drift-guard comment + validate() extra-key check:
- Reword the drift-guard comment to correctly describe its one-directional
  nature (TS struct check catches missing SectionName keys; extra JSON keys
  slip through structurally).
- Add a runtime validate() loop that enumerates actual config keys against a
  hardcoded VALID_SECTION_NAMES set and throws on any unexpected key, closing
  the gap the compile-time guard misses.

#111 — L2 head-noun anchor fallback in matchSectionHeader (fixes #108):
- Add matchAnchorFallback(): fires only when exact-alias and split-letter
  paths both miss; matches a qualified header ("Relevant Experience",
  "Customer Service Experience") by requiring its last token to be an anchor
  from SECTION_ANCHORS for an anchor-fallback-enabled section.
- Seven guardrails (length ≤40, word count ≤4, head-noun-last, no terminal
  punctuation, not a bullet line, anchorFallback flag, header casing) prevent
  prose FPs confirmed against the corpus.
- Export SECTION_ANCHORS and SECTION_ANCHOR_FALLBACKS from sections.config.ts
  (and re-export via regex.ts); skills/other stay off the fallback path so a
  flattened two-column sidebar label cannot strand experience entries.
- Add synthetic fixture chromium-qualified-experience-headers.pdf +
  .expected.json (Chromium-rendered, synthetic persona).
- Re-bake four corpus snapshots whose section counts improved with L2
  (google-docs-skia-proxy-nonstandard-headers, chromium-two-column-sidebar,
  weasyprint-cairo-nonstandard-headers, openresume-laverne-word-quartz).

#112 — L3 visual-primary boundary detection in splitIntoSections:
- Font-ratio ≥1.2 (larger than body average) is now the sole visual signal
  for a section boundary; the allCaps branch is dropped after corpus
  validation showed it was a net FP source.
- Name/contact block at the top is disambiguated from body section headers
  so the first non-keyword visual-distinct line after the contact block
  opens an experience/education section rather than being swallowed.
- Add sections.test.ts: 12 direct unit tests for splitIntoSections' visual
  path (first direct coverage; previously exercised only transitively).

Tests: 438/438 pass (vitest).

Closes #108
Resolves #111
Resolves #112
Resolves #114

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@s-annam
s-annam requested a review from Samhit21 June 17, 2026 20:50
Comment thread src/lib/heuristics/regex.ts Fixed
Comment thread src/lib/heuristics/regex.ts Fixed
…litIntoSections

PR #115 fallow audit flagged two issues in the changed set:

- Unused exports: SECTION_ANCHORS / SECTION_ANCHOR_FALLBACKS were re-exported
  from regex.ts but have no out-of-module consumer (matchAnchorFallback uses
  the local import). Drop the re-export; consumers import from
  sections.config.ts (canonical home).
- High complexity: splitIntoSections (L3 #112) hit 9 cyclomatic / 16 cognitive
  by inlining the keyword/visual/name-contact decision in the loop. Extract a
  pure classifyLine() helper so the splitter loop stays flat — behavior
  identical (438/438 tests green), function now under threshold.

orderItemsByColumn remains above threshold but is pre-existing/inherited
(gate-excluded) — out of scope for this PR.

Refs #115

@Samhit21 Samhit21 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.

PR Review: feat: section-recognition L2/L3 — anchor fallback + visual bounds

Summary

Clean L2 + L3 land. The 7-guardrail framing for the anchor fallback is the right shape, the name/contact disambiguation in splitIntoSections is a real fix (not a workaround), and dropping allCaps as
a visual signal after the corpus pass is the kind of decision the reasoning round-trips back into the docstrings — well-evidenced. New direct unit coverage for splitIntoSections plus the synthetic
Chromium fixture for the qualified-header path closes the right loops. validate()'s extra-key runtime check addresses the one-directional _drift gap from #114 cleanly.

Prior review activity: two fallow bot comments about SECTION_ANCHORS/SECTION_ANCHOR_FALLBACKS being unused outside the module — already addressed in the in-file comment at regex.ts:105-108 and not
re-flagging.

Findings

  1. [Suggestion] Guard 7's casing check at regex.ts:172-176 lets digit-led tokens slip ("5 Years Experience", "10+ Years Experience"). /[a-z]/.test(first) is false for digits, so the negative
    check passes when the positive check wouldn't. Trivial swap to /[A-Z]/.test(first) plus a fixture row in the FP table.
  2. [Suggestion] validate() in sections.config.ts:68-100 doesn't type-check the booleans or enforce anchor uniqueness across anchorFallback: true sections. Future-config hazard for the
    load-bearing L2 contract.
  3. [Nit] hasContactShape in sections.ts:306-311 mutates exported /g regex state. Works today; pattern is fragile if a future caller reorders or runs after these regexes have an in-flight
    iterator.
  4. [Question] sections.config.json routes the "qualifications" anchor to education. Common US-convention reading is closer to skills/profile; worth an inline comment pinning the intent
    (especially if the corpus you're tuning against is residency/CV-leaning).

Verdict

Action: COMMENT — none of the four are merge-blockers. #1 is the highest-value follow-up; the FP risk is real but contained to a few uncommon header shapes.

Comment thread src/lib/heuristics/regex.ts
Comment thread src/lib/heuristics/sections.config.ts
Comment thread src/lib/heuristics/sections.ts Outdated
Three reviewer threads, all closed in code:

- Guard 7 casing (regex.ts): require an UPPERCASE lead char, not merely
  "not lowercase". Digit/symbol-led tokens ("5 Years Experience",
  "10+ Years Experience") previously cleared the guard and could open a
  false experience boundary mid-summary. +3 negative tests. This also
  closes the incidental "20% Projects" column-flatten match on
  chromium-two-column-sidebar (rebaked snapshot, projectsCount 2→0) —
  that recovery rode the same hole and is indistinguishable from prose.

- validate() (sections.config.ts): type-check the boolean flags and
  enforce cross-section anchor uniqueness for anchorFallback:true
  sections (matchAnchorFallback returns on first hit). Split into
  validateSection/validateAnchorUniqueness to stay under the complexity gate.

- hasContactShape (sections.ts): stop mutating shared /g regex lastIndex;
  use non-global clones derived from the regex.ts .source (single source).

Refs #115
@s-annam

s-annam commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

@Samhit21 all three review threads addressed and resolved in e4ea6a5 — ready for final review.

  • Guard 7 casing → require uppercase lead; 5 Years Experience / 10+ Years Experience / 3 Years Experience pinned in regex.test.ts.
  • validate() → boolean-type checks + cross-section anchor uniqueness, split into helpers to stay under the fallow gate.
  • hasContactShape → non-global regex clones, no shared lastIndex mutation.

Your Guard 7 catch also closed an incidental 20% Projects column-flatten match on chromium-two-column-sidebar (snapshot rebaked, projectsCount 2→0). Recovering that properly is its own concern — filed as #117 (lean toward the L3 visual-path attribution route), out of scope here.

Gates green: typecheck clean · 439/439 tests · fallow audit ✓ no issues in changed files. Requesting your approval when you get a chance. 🙏

@s-annam
s-annam merged commit d45fa67 into main Jun 17, 2026
2 checks passed
@s-annam
s-annam deleted the section-recognition-l2-l3 branch June 17, 2026 23:12
s-annam added a commit that referenced this pull request Jun 18, 2026
)

Recover section headers glued to a sidebar bar-value in two-column flattens (e.g. `20% Projects`), regressed by #115's Guard 7 tightening. Gated on secondary-column membership; text-only matchSectionHeader path untouched so prose FP pins stay null.

Resolves #117
s-annam added a commit that referenced this pull request Jun 25, 2026
Lands L2 + L3 of the section-recognition epic (#109, on merged L1 #110), plus #113-review follow-up #114.

- #114 — sections.config.ts _drift guard comment reworded to its true one-directional nature + runtime validate() extra-key check.
- #111 (L2) — head-noun anchor fallback in matchSectionHeader behind 7 FP guardrails; exports SECTION_ANCHORS + SECTION_ANCHOR_FALLBACKS; skills/other stay off the fallback. Synthetic Chromium fixture added.
- #112 (L3) — visual-primary boundary in splitIntoSections (font-ratio >= 1.2) with name/contact disambiguation; first direct unit coverage for splitIntoSections. allCaps dropped as a net-FP visual signal.

Two-column flattened-header sidebar-noise regression tracked separately as #117.

Closes #108
Closes #111
Closes #112
Closes #114
s-annam added a commit that referenced this pull request Jun 25, 2026
)

Recover section headers glued to a sidebar bar-value in two-column flattens (e.g. `20% Projects`), regressed by #115's Guard 7 tightening. Gated on secondary-column membership; text-only matchSectionHeader path untouched so prose FP pins stay null.

Resolves #117
s-annam added a commit that referenced this pull request Jun 28, 2026
Lands L2 + L3 of the section-recognition epic (#109, on merged L1 #110), plus #113-review follow-up #114.

- #114 — sections.config.ts _drift guard comment reworded to its true one-directional nature + runtime validate() extra-key check.
- #111 (L2) — head-noun anchor fallback in matchSectionHeader behind 7 FP guardrails; exports SECTION_ANCHORS + SECTION_ANCHOR_FALLBACKS; skills/other stay off the fallback. Synthetic Chromium fixture added.
- #112 (L3) — visual-primary boundary in splitIntoSections (font-ratio >= 1.2) with name/contact disambiguation; first direct unit coverage for splitIntoSections. allCaps dropped as a net-FP visual signal.

Two-column flattened-header sidebar-noise regression tracked separately as #117.

Closes #108
Closes #111
Closes #112
Closes #114
s-annam added a commit that referenced this pull request Jun 28, 2026
)

Recover section headers glued to a sidebar bar-value in two-column flattens (e.g. `20% Projects`), regressed by #115's Guard 7 tightening. Gated on secondary-column membership; text-only matchSectionHeader path untouched so prose FP pins stay null.

Resolves #117
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