F03 — application records
- Quota already removed with F02's tracker; verified uncapped and that
"interview reached" survives a later withdrawal.
- Aker BP: application date confirmed 2026-07-29 by the user, settling the
one-day conflict. Its Adjacent-despite-79 classification is kept, NOT
reclassified: the user does not remember the rationale and judged it
immaterial on a closed role, and silently reclassifying to make the
arithmetic tidy is the exact objection the review raised. Recorded as an
explicit override reason with a rubric version instead.
F04 — rule hierarchy
- Three levels stated in AGENTS.md and CLAUDE.md: immutable evidence rules,
standing user preferences, market/employer presentation.
- Deliverables separated from QA. The .tex stays the artefact of record; you
still compile, into the application's own output/ folder, because the user
cannot approve what he cannot open.
- `resume_reference.md` said "Capgemini/Bundeswehr: include only when directly
relevant", contradicting the standing never-include rule. Split and fixed.
F05 — a green validator now means something
- Per-claim `forbidden` wording is enforced. 14 of 26 claims carried such a
list and NONE of it was checked, which is why "owned the full ML lifecycle"
passed. Matching tolerates inserted adjectives: the real regression read
"Built a decentralized Data Mesh" and one word defeated the literal list.
- Added identity (e-mail/phone), employer-date, placeholder and
document-exclusion checks. Capgemini's omission rule now has a canonical
home in claims.json instead of living only in memory and config.md.
- Registered Groovy and Snowflake as forbidden skills. Both were absent from
claims.json entirely, so "forbidden" existed in prose with nothing enforcing
it.
- PASS no longer reads as a truth certificate: it prints what it did not check
— scope implications, bullet-to-claim support, whether a number is real.
F06 — the scanner stopped fighting the documents
- LaTeX comments are no longer claims; `% never claim Terraform` was a finding
before, which taught the workflow to write around the checker.
- Markup, accent macros and umlauts are normalized, so `\textbf{Snow}flake`
cannot smuggle a forbidden skill past a substring test and
`Universit\"at der Bundeswehr M\"unchen` is recognisably a school.
- Honest denials pass in English and German.
- The triple-list cadence hint knew only English "and", so it silently did
nothing on every German package. Advisory only, and now labelled as such.
Verification
- The six counter-proofs from the report are caught: 9 errors on one synthetic
document, where the old scanner found none.
- All six packages built under current rules scan clean — SBB, NVIDIA, RUAG,
Kdo Cy, Citadel, Aker BP. Four false positives of my own were found and fixed
on the way there; each is now a regression test.
- 13 older packages do report findings. Those look like genuine historical
violations and are deliberately NOT fixed here: F17 governs archives and they
must not be silently rewritten.
- `tests/test_document_scanner.py` added. Full suite 31/31 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
407 lines
18 KiB
Python
407 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate canonical resume data, generation sources, and generated documents.
|
|
|
|
Usage:
|
|
python resume_builder/helpers/validate_resume_system.py
|
|
python resume_builder/helpers/validate_resume_system.py --document output/Acme/resume.tex
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
CLAIMS_PATH = ROOT / "resume_builder" / "canonical" / "claims.json"
|
|
HISTORY_PATH = ROOT / "resume_builder" / "canonical" / "historical_outputs.json"
|
|
|
|
|
|
def load_json(path: Path) -> dict:
|
|
with path.open(encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def canonical_checks(claims: dict, history: dict) -> tuple[list[str], list[str]]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
if claims.get("schema_version") != 1:
|
|
errors.append("claims.json schema_version must be 1")
|
|
if history.get("schema_version") != 1:
|
|
errors.append("historical_outputs.json schema_version must be 1")
|
|
|
|
for key in ("identity", "education", "employment", "claims", "skills"):
|
|
if not claims.get(key):
|
|
errors.append(f"claims.json missing non-empty {key}")
|
|
|
|
for collection in ("education", "employment", "claims"):
|
|
ids = [item.get("id") for item in claims.get(collection, [])]
|
|
if None in ids:
|
|
errors.append(f"{collection} contains an item without id")
|
|
duplicates = sorted({item for item in ids if ids.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"duplicate {collection} ids: {', '.join(duplicates)}")
|
|
|
|
skills = claims.get("skills", [])
|
|
skill_names = [item.get("name") for item in skills]
|
|
duplicates = sorted({item for item in skill_names if skill_names.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"duplicate skills: {', '.join(duplicates)}")
|
|
for skill in skills:
|
|
if skill.get("output") not in {
|
|
"allowed",
|
|
"allowed-with-context",
|
|
"certification-context-only",
|
|
"forbidden",
|
|
}:
|
|
errors.append(f"invalid output policy for skill {skill.get('name')}")
|
|
|
|
config = read_text(ROOT / "config.md")
|
|
bundle_names = re.findall(r"\|\s*(bundle_[a-z0-9_]+\.md)\s*\|", config)
|
|
for bundle_name in bundle_names:
|
|
if not (ROOT / "resume_builder" / "bundles" / bundle_name).exists():
|
|
errors.append(f"configured bundle does not exist: {bundle_name}")
|
|
|
|
expected_files = [
|
|
ROOT / "resume_builder" / "templates" / "resume_template.tex",
|
|
ROOT / "resume_builder" / "reference" / "resume_reference.md",
|
|
ROOT / "resume_builder" / "reference" / "critique_framework.md",
|
|
]
|
|
for path in expected_files:
|
|
if not path.exists():
|
|
errors.append(f"required workflow file missing: {path.relative_to(ROOT)}")
|
|
|
|
history_folders: list[str] = []
|
|
for key in ("unsafe_do_not_reuse", "historical_revalidate"):
|
|
for item in history.get(key, []):
|
|
history_folders.append(item["folder"] if isinstance(item, dict) else item)
|
|
duplicates = sorted({item for item in history_folders if history_folders.count(item) > 1})
|
|
if duplicates:
|
|
errors.append(f"historical output folders classified twice: {', '.join(duplicates)}")
|
|
|
|
if claims["identity"].get("swiss_permit", "").startswith("UNVERIFIED"):
|
|
warnings.append("Swiss permit type remains unverified; generators must ask before naming it")
|
|
return errors, warnings
|
|
|
|
|
|
UNCHECKED = (
|
|
"whether a sentence implies ownership or scale beyond the evidence (Scope Discipline)",
|
|
"whether each bullet is actually supported by the claim it was planned from",
|
|
"whether a number in the document is real - unverified metrics look identical to real ones",
|
|
"whether tone, emphasis and ordering suit this employer",
|
|
"anything in a language or phrasing the pattern lists do not cover",
|
|
)
|
|
|
|
|
|
def workflow_checks() -> list[str]:
|
|
errors: list[str] = []
|
|
checks = {
|
|
ROOT / "resume_builder" / "templates" / "resume_template.tex": [
|
|
"Research Experience",
|
|
"FLIPPED format",
|
|
"Selected Publications",
|
|
],
|
|
ROOT / "resume_builder" / "reference" / "resume_reference.md": [
|
|
"ALL variable bullets",
|
|
"<= 3 lines white space",
|
|
"20-21 variable bullets",
|
|
],
|
|
ROOT / "resume_builder" / "reference" / "workflows" / "critique.md": [
|
|
"Publications 10%",
|
|
"Eight-Dimension Scoring",
|
|
],
|
|
}
|
|
for path, forbidden_phrases in checks.items():
|
|
text = read_text(path).lower()
|
|
for phrase in forbidden_phrases:
|
|
if phrase.lower() in text:
|
|
errors.append(f"obsolete workflow rule remains in {path.relative_to(ROOT)}: {phrase}")
|
|
return errors
|
|
|
|
|
|
_COMMENT = re.compile(r"(?<!\\)%.*$", re.M)
|
|
_MARKUP = re.compile(r"\\[a-zA-Z@]+\s*(?:\[[^\]]*\])?")
|
|
_PLACEHOLDER = re.compile(r"\[GENERATE[^\]]*\]|\bTODO\b|\bFIXME\b|\bXXX\b|\bLOREM IPSUM\b|<<[^>]*>>", re.I)
|
|
# "no Terraform experience", "keine Terraform-Erfahrung", "not worked with Terraform"
|
|
_NEGATION = re.compile(
|
|
r"\b(?:no|not|never|without|kein|keine|keinerlei|ohne|nicht)\b[^.;]{0,60}$", re.I)
|
|
|
|
|
|
# LaTeX accent macros: \"a, 'e, \`e, \^o, \~n, plus \ss. German documents use these instead
|
|
# of literal umlauts, and leaving them in made "Universit\"at der Bundeswehr" unmatchable.
|
|
_ACCENT = re.compile(r"""\\["'`^~=.]\s*\{?([a-zA-Z])\}?""")
|
|
_SSHARP = re.compile(r"\\ss\b")
|
|
|
|
|
|
def _deaccent(raw: str) -> str:
|
|
"""Fold LaTeX accent macros to their base letter.
|
|
|
|
German packages write Universit\\"at der Bundeswehr M\\"unchen rather than literal
|
|
umlauts. Without this the employer/education comparison could not see the institution
|
|
name at all, which produced a false positive on several correct resumes.
|
|
"""
|
|
return _SSHARP.sub("ss", _ACCENT.sub(r"\1", raw))
|
|
|
|
|
|
def visible_text(raw: str) -> str:
|
|
"""Document text as a reader sees it.
|
|
|
|
Three fixes for the scanner, all from review finding F06:
|
|
* LaTeX COMMENTS are dropped. A line such as `% never claim Terraform` is a note to
|
|
the author, not a claim, and flagging it taught the workflow to write around the
|
|
checker instead of fixing the document.
|
|
* MARKUP is removed but its argument text kept, so `\\textbf{Snow}flake` can no longer
|
|
smuggle a forbidden skill past a plain substring comparison.
|
|
* Whitespace is collapsed so a term split across a line break still matches.
|
|
"""
|
|
text = _deaccent(_COMMENT.sub("", raw))
|
|
text = _MARKUP.sub(" ", text)
|
|
text = text.replace("{", " ").replace("}", " ").replace("~", " ")
|
|
return re.sub(r"\s+", " ", text)
|
|
|
|
|
|
@lru_cache(maxsize=512)
|
|
def _forbidden_phrase(phrase: str) -> re.Pattern:
|
|
"""Forbidden wording, tolerant of inserted qualifiers.
|
|
|
|
The canonical lists are literal ("built a Data Mesh"), but the regression this rule
|
|
exists to stop was worded "Built a decentralized Data Mesh" - one adjective defeated the
|
|
substring test. Allow up to two extra words between tokens. A false positive here costs
|
|
a rewording; a false negative ships a scope claim the evidence does not support.
|
|
"""
|
|
tokens = [re.escape(token) for token in phrase.lower().split()]
|
|
return re.compile(r"(?<!\w)" + r"\W+(?:\w+\W+){0,2}".join(tokens), re.I)
|
|
|
|
|
|
_FOLD = str.maketrans({"ä": "a", "ö": "o", "ü": "u", "ß": "ss",
|
|
"Ä": "a", "Ö": "o", "Ü": "u", "é": "e", "è": "e"})
|
|
|
|
|
|
def fold(text: str) -> str:
|
|
"""Umlaut-insensitive form. claims.json stores 'Universitat der Bundeswehr Munchen'
|
|
while documents render 'Universitaet der Bundeswehr Muenchen', and that mismatch made an
|
|
education institution look like the Bundeswehr employment entry on three correct resumes."""
|
|
return text.translate(_FOLD).lower()
|
|
|
|
|
|
def _mentions(haystack: str, term: str) -> bool:
|
|
return re.search(r"(?<!\w)" + re.escape(term.lower()) + r"(?!\w)", haystack) is not None
|
|
|
|
|
|
def _negated(haystack: str, term: str) -> bool:
|
|
"""True when every mention of the term sits behind a negation."""
|
|
hits = list(re.finditer(r"(?<!\w)" + re.escape(term.lower()) + r"(?!\w)", haystack))
|
|
return bool(hits) and all(_NEGATION.search(haystack[max(0, hit.start() - 70):hit.start()]) for hit in hits)
|
|
|
|
|
|
def scan_document(path: Path, claims: dict) -> list[str]:
|
|
"""Deterministic, mechanically checkable properties of a generated document.
|
|
|
|
This is NOT a truth check and must never be reported as one. It cannot judge whether a
|
|
sentence implies ownership the evidence does not support; that stays a reading task.
|
|
See `UNCHECKED` and the summary printed by main().
|
|
"""
|
|
errors: list[str] = []
|
|
raw = read_text(path)
|
|
text = visible_text(raw)
|
|
lowered = text.lower()
|
|
# Second view with grouping braces closed up rather than spaced out, so a name split by
|
|
# markup -- extbf{Snow}flake -- cannot slip past the skill comparison.
|
|
dense = re.sub(r"\s+", " ", _MARKUP.sub(" ", _deaccent(_COMMENT.sub("", raw))).replace("{", "").replace("}", "")).lower()
|
|
|
|
for pattern in claims.get("global_forbidden_output_patterns", []):
|
|
if pattern.lower() in lowered:
|
|
errors.append(f"{path}: forbidden output pattern: {pattern}")
|
|
|
|
# Per-claim forbidden wording. 14 of 26 claims carry a `forbidden` list and NONE of it
|
|
# was enforced before: exactly the gap that let "owned the full ML lifecycle" pass.
|
|
for claim in claims.get("claims", []):
|
|
for phrase in claim.get("forbidden", []):
|
|
if not isinstance(phrase, str) or len(phrase) < 8:
|
|
continue # too short to match safely as a substring
|
|
if _forbidden_phrase(phrase).search(lowered):
|
|
errors.append(f"{path}: {claim.get('id','?')} forbidden wording: {phrase}")
|
|
|
|
for skill in claims.get("skills", []):
|
|
name, output = skill["name"], skill.get("output")
|
|
seen_plain = _mentions(lowered, name)
|
|
seen_dense = _mentions(dense, name)
|
|
if output == "forbidden" and (seen_plain or seen_dense) and not _negated(lowered if seen_plain else dense, name):
|
|
errors.append(f"{path}: forbidden or unverified skill: {name}")
|
|
if output == "certification-context-only" and _mentions(lowered, name):
|
|
window = " ".join(
|
|
lowered[max(0, m.start() - 200):m.start() + 200]
|
|
for m in re.finditer(re.escape(name.lower()), lowered))
|
|
if not re.search(r"certificat|zertifik|coursera|nanodegree|kurs|course|training|weiterbildung", window):
|
|
errors.append(
|
|
f"{path}: {name} is certification-context-only and appears without that context")
|
|
|
|
for term in (exclusion["term"] for exclusion in claims.get("document_exclusions", [])):
|
|
if _mentions(lowered, term):
|
|
errors.append(f"{path}: excluded from all documents by standing user preference: {term}")
|
|
|
|
# Scan the comment-stripped RAW text: _MARKUP treats "\item [GENERATE: x]" as a command
|
|
# with an optional argument and would swallow the placeholder whole.
|
|
for match in _PLACEHOLDER.finditer(_COMMENT.sub("", raw)):
|
|
errors.append(f"{path}: unresolved placeholder left in the document: {match.group(0)!r}")
|
|
|
|
identity = claims.get("identity", {})
|
|
for field in ("email", "phone"):
|
|
value = identity.get(field)
|
|
if not value:
|
|
continue
|
|
compact = re.sub(r"[\s()-]", "", lowered)
|
|
if re.sub(r"[\s()-]", "", value.lower()) not in compact:
|
|
kind = "e-mail" if field == "email" else "phone number"
|
|
errors.append(f"{path}: canonical {kind} not found ({value}); check for a wrong contact line")
|
|
|
|
# Anchor the date check on the EMPLOYER NAME, never on the job title: titles collide
|
|
# across employers ("Research Software Engineer" contains "Software Engineer") and that
|
|
# produced a false positive on two real resumes. Employers excluded from documents are
|
|
# skipped outright - their absence is the point, not a defect.
|
|
excluded = {item["term"].lower() for item in claims.get("document_exclusions", [])}
|
|
schooling_removed = fold(lowered)
|
|
for school in claims.get("education", []):
|
|
for key in ("institution", "thesis_institution"):
|
|
institution = fold(school.get(key) or "")
|
|
if len(institution) > 5:
|
|
schooling_removed = schooling_removed.replace(institution, " ")
|
|
for employer in claims.get("employment", []):
|
|
name = (employer.get("employer") or "").lower()
|
|
if not name or any(term in name for term in excluded):
|
|
continue
|
|
short = re.split(r"\s*\(|,| gmbh| ag| asa", name)[0].strip()
|
|
# Education institutions are removed first: "Universitaet der Bundeswehr Muenchen" is a
|
|
# school, not the Bundeswehr employment entry, and matching it flagged three real
|
|
# resumes that were entirely correct.
|
|
if len(short) < 4 or not _mentions(schooling_removed, fold(short)):
|
|
continue
|
|
start = employer.get("start", "")
|
|
year = start.split("-")[0] if start else ""
|
|
if year and year not in text:
|
|
errors.append(
|
|
f"{path}: {employer.get('id','?')} ({employer.get('employer')}) appears but its "
|
|
f"canonical start year {year} is absent; verify the dates")
|
|
|
|
if "\\begin{rsubsection}" in raw.lower():
|
|
for line_number, line in enumerate(raw.splitlines(), 1):
|
|
if "\\begin{rSubsection}" not in line:
|
|
continue
|
|
if re.search(r"Production Ownership|AI-Ready|Customer-Embedded|Shipping ML", line, re.I):
|
|
errors.append(f"{path}:{line_number}: marketing theme used before formal employer/title")
|
|
return errors
|
|
|
|
|
|
# Advisory only. German documents use "und"; without it the cadence hint silently
|
|
# did nothing on every German package. No external evidence supports a hard ~50% limit,
|
|
# so this stays a hint and never an error (F06).
|
|
TRIPLE = re.compile(r"\w+, [^,]{3,45}, [^,]{3,45} (?:and|und) |\w+, [^,]{3,45} (?:and|und) ")
|
|
|
|
|
|
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")
|
|
args = parser.parse_args()
|
|
|
|
claims = load_json(CLAIMS_PATH)
|
|
history = load_json(HISTORY_PATH)
|
|
errors, warnings = canonical_checks(claims, history)
|
|
errors.extend(workflow_checks())
|
|
for document in args.document:
|
|
path = Path(document)
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
if not path.exists():
|
|
errors.append(f"document not found: {path}")
|
|
else:
|
|
errors.extend(scan_document(path, claims))
|
|
warnings.extend(cadence_checks(path))
|
|
|
|
if args.document:
|
|
print("Mechanical checks only. NOT verified automatically:")
|
|
for item in UNCHECKED:
|
|
print(f" - {item}")
|
|
print()
|
|
for warning in warnings:
|
|
print(f"WARN (advisory, never a gate): {warning}")
|
|
for error in errors:
|
|
print(f"ERROR: {error}")
|
|
if errors:
|
|
print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)")
|
|
return 1
|
|
scope = "mechanical checks passed" if args.document else "canonical system valid"
|
|
print(f"PASS: {scope} ({len(warnings)} advisory warning(s)). "
|
|
"This is not a truth certificate - see the unchecked list above.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|