Ariadne: CV parsing research and a parser spine

A dependency-free Python parser that reports what the extraction lost, with typed failure codes, beside a research corpus where every claim carries a source id and a grade.

Evidence tier: Verified | code for the parser and the research corpus described below, at version 0.0.1 in Ariadne. Nothing on this page claims a hosted service, a user, or a measurement against a real employer’s system, because none exists.

Added 2026-10-26 and linked from the homepage as a project card. It is a card and not a case study on purpose: the code is the artifact, and a case study would imply a deployment the repository does not have.

What it is

A research repository plus a small Python package, built around one narrow question: what happens to a CV between “upload” and “a human reads it”, and what can honestly be measured about that. Two halves, deliberately kept apart.

docs/ is the research: ten pages, about 1,640 lines, every claim carrying a source id and a grade of A, B, C or U, every page a freshness date, and a source register that CI checks in both directions so a citation cannot dangle and a source cannot go uncited.

src/ariadne_ats/ is the implementation of only the parts that can be built without a model: text normalisation, layout-damage detection with named failure codes, section segmentation with per-section confidence, a fact ledger, a job-description requirement extractor, and a TF-IDF comparison that prints the terms behind its number. 1,443 lines, 4 test modules, 6 fixtures.

Architecture

  cv.docx / cv.pdf / cv.txt
        |
        v
  extract     DOCX through the stdlib (a DOCX is a zip of XML: zipfile plus ElementTree, no
              dependency at all). PDF through an optional backend (pymupdf, else pypdf).
              An image-only PDF reaches OCR only if an OCR backend is installed.
        |
        v
  textutil    normalisation: whitespace, hyphenation, ligatures, the quiet damage a converter does
        |
        v
  layout      damage detection with named codes: multicolumn flattening, a header/footer band
              leaking into the body, folded ligatures, text yield below a usable floor
        |
        v
  segment     section boundaries, each with a confidence rather than a bare True
        |
        v
  evidence    the ledger: fact id, source span, confidence, and two hashes per fact
        |
        v
  jd, lexical  requirement extraction with weights and spans, then TF-IDF cosine that prints
               the terms that produced the number

  errors runs under all of it: a Code plus a Severity (BLOCKING or DEGRADED) per finding.

Key decisions

Deterministic, and dependency-free on purpose. dependencies = [] in the package metadata, with pytest the only dev extra, Python 3.10 or newer, and no network call in the baseline path. The reasoning: a measurement tool whose output depends on a model revision or a network round trip is not a measurement tool, and the extraction that needs a third-party library says so with a typed failure instead of quietly degrading.

Failures are typed, because a plausible empty result is the dangerous output. PDF_BACKEND_MISSING, OCR_BACKEND_MISSING, NO_TEXT_LAYER, CORRUPT_DOCUMENT, UNSUPPORTED_FORMAT, EMPTY_INPUT, LOW_TEXT_YIELD, MULTICOLUMN_FLATTENED, HEADER_FOOTER_BAND, LIGATURE_FOLDED. A document that cannot be read produces a code and a severity, not a page of blanks that reads like a sparse CV.

No scoring, and not by taste. The published evidence this project graded A is that a ranking function of this class reverses the order of candidates on 29.6 % of pairs where competence was deliberately preserved (validity 0.781 on matched-pair reversals, docs/03 section 3.4). So there is no “score my CV” command in the CLI at all: adding a flag that implied a score would be a user-facing claim. What ships instead is compare, which prints a cosine and the terms that produced it, so the number can be argued with.

Grades are enforced by CI, not by discipline. Every claim in docs/ carries a source id and one of four grades, and the register is checked in both directions by the build. U means “attempted, unanswered” and is a real value a page can carry; it is not a placeholder waiting to be quietly deleted.

Person-attribute inference is refused structurally. The contract rejects it, and tests/test_no_profiling.py fails the build on it. A rule that only exists in a policy file is a rule that decays.

What broke

The authoritative sources answered with everything except an answer. Eight applicant-tracking help endpoints were queried for their parser identity and accepted formats. The responses were a 404, a 401, a Cloudflare JavaScript shell, and a connection failure, and the vendor-identity query returned zero results: 0 of 8 targets answered, 7 of 8 unknown on accepted formats. Those failures are recorded and graded A as records of failure, and the unknowns are written as U. The tempting alternative was to summarise what the internet believes about ATS vendors, which would have produced a confident page built on nobody’s knowledge.

The one official-grade artifact held is a sandbox subdomain. It is labelled as a sandbox on the page that cites it, because a sandbox limit is not a production limit and the difference is the conclusion.

A DOCX is a zip of XML, so the zero-dependency path works, and a malformed one raises CORRUPT_DOCUMENT. An image-only PDF has no text layer, and before the typed codes existed that document parsed “successfully” into nothing. The fix was not better extraction but a code that says what happened.

Known limitations

What is not claimed

how claims are graded · back to the homepage