feat(resume): check bullet cadence variety, warn-only

An audit of all 18 packages in output/ (368 bullets) found one rhythm
running through every document: 45% of bullets used the same "X, Y and
Z" triple, and the SBB package reached 85% - 11 of 13 bullets, every
Swisscom and Bosch line - plus two adjacent bullets both opening
"Build and...".

This is a style finding, not a truth finding. Every bullet was accurate.
The corpus is clean on the axes that actually signal generated text: no
AI vocabulary (0 hits for leverage/spearheaded/robust/passionate and 56
others across 35 documents), prose em-dashes at 0.08/bullet, and PDF
metadata carrying nothing but MiKTeX pdfTeX with empty Author/Title
(0 AI tokens and 0 generator-term leaks across 69 PDFs). What is left is
cadence: ten bullets sharing one three-beat rhythm read as machine-made
even when nothing in them is false.

Guarded deliberately so it cannot do harm. cadence_checks() emits WARN
and never ERROR, the critique deduction caps at 1 point, and both the
reference and the docstring state that no claim, scope or hedged verb
may be bent to satisfy rhythm. An anti-monotony rule with teeth would be
worse than the problem - it would pressure a future run into loosening a
scoped claim to vary a sentence.

Thresholds are calibrated on the corpus, not guessed. Position length
spreads are bimodal (1-9 words, then 15-17), so the check flags a spread
of <=4, the tight tail at ~28% of positions; the first draft used <=6
and flagged the median. Also fixed the opening-verb extractor, which
read \textbf{Owned ...} as the word "textbf" and produced six false
positives on one document. Per-package warnings now run 0-3.

Docs: resume_reference.md 7a + verification step, critical_rules.md 7a,
critique_framework.md mechanics row, CLAUDE.md corrections log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHtzyTKBcg6BWhD5qFegtK
This commit is contained in:
2026-08-25 09:38:19 +02:00
co-authored by Claude Opus 5
parent af7189a93d
commit 92a81a7dcb
5 changed files with 99 additions and 1 deletions
@@ -137,6 +137,67 @@ def scan_document(path: Path, claims: dict) -> list[str]:
return errors
TRIPLE = re.compile(r"\w+, [^,]{3,45}, [^,]{3,45} and |\w+, [^,]{3,45} and ")
def _bullet_blocks(text: str) -> list[list[str]]:
"""Bullets grouped by rSubsection (one group per position)."""
body = re.search(r"\\begin\{document\}(.*)\\end\{document\}", text, re.S)
body_text = body.group(1) if body else text
blocks = re.split(r"\\begin\{rSubsection\}", body_text)[1:]
return [re.findall(r"\\item\s+(.+)", block) for block in blocks]
def _visible_text(bullet: str) -> str:
"""Bullet text with LaTeX markup removed, so \\textbf{Owned ...} reads as 'Owned ...'."""
text = re.sub(r"\\[a-zA-Z]+\*?(\[[^]]*\])?", " ", bullet)
return re.sub(r"[{}$\\]", " ", text)
def _opening_word(bullet: str) -> str:
match = re.search(r"[A-Za-z]+", _visible_text(bullet))
return match.group(0).lower() if match else ""
def cadence_checks(path: Path) -> list[str]:
"""Bullet cadence diagnostics (resume_reference.md 7a).
Warnings only, never errors. Uniform rhythm is a style flaw, not a truth flaw:
no claim, scope or hedged verb may be bent to satisfy these. Reshape sentences.
"""
blocks = _bullet_blocks(read_text(path))
bullets = [b for block in blocks for b in block]
if len(bullets) < 6:
return []
warnings: list[str] = []
triples = [b for b in bullets if TRIPLE.search(b)]
share = len(triples) / len(bullets)
if share > 0.5:
warnings.append(
f"{path}: {len(triples)}/{len(bullets)} bullets ({share:.0%}) use an "
f"'X, Y and Z' triple (reference 7a: under ~50%). Reshape sentences, not claims."
)
for position, block in enumerate(blocks, 1):
for index in range(1, len(block)):
previous, current = _opening_word(block[index - 1]), _opening_word(block[index])
if previous and previous == current:
warnings.append(
f"{path}: position {position}, bullets {index}-{index + 1} both open "
f"with '{previous}'"
)
# Threshold calibrated on the 50 multi-bullet positions in output/: spreads are
# bimodal (1-9, then 15-17). <=4 flags the tight tail (~28%), not the median.
lengths = [len(_visible_text(b).split()) for b in block]
if len(lengths) >= 4 and max(lengths) - min(lengths) <= 4:
warnings.append(
f"{path}: position {position} bullets cluster at {min(lengths)}-{max(lengths)} "
f"words; vary length within the position, not just across the document."
)
return warnings
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--document", action="append", default=[], help="Generated .tex file to validate")
@@ -154,6 +215,7 @@ def main() -> int:
errors.append(f"document not found: {path}")
else:
errors.extend(scan_document(path, claims))
warnings.extend(cadence_checks(path))
for warning in warnings:
print(f"WARN: {warning}")