Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d8c47b
gate: make green mean green -- fail-closed evidence, and one product …
ebt55 Jul 29, 2026
5ee2651
backend: the permissive runtime boundary -- convert with PyMuPDF absent
ebt55 Jul 29, 2026
952f7a9
gate: close audit-found false-green paths
ebt55 Jul 30, 2026
1893d34
parity: attribute both unwaived regressions, and scope floors to thei…
ebt55 Jul 30, 2026
d41bf57
corpus: freeze the 16 inputs, and make "canonical" mean an exact tool…
ebt55 Jul 30, 2026
d1f9781
fonts: pin what the renderer can see, and re-record against it
ebt55 Jul 30, 2026
50f38a0
docs: record the confirmed CI state -- green except parity, lanes bit…
ebt55 Jul 30, 2026
82d7602
docs: record the PR transition, and what verifying it disproved
ebt55 Jul 30, 2026
8ecb444
gate: make canonical mean exact, and split accepted into provisional …
ebt55 Jul 30, 2026
4b232d0
tests: a test that converts nothing is not a proof
ebt55 Jul 30, 2026
2f1645c
env: make the canonical environment an artifact with a digest
ebt55 Jul 30, 2026
ecbdcc4
parity: the documented way to record floors could never have run
ebt55 Jul 30, 2026
8a14f77
parity: remeasure the stale floors, and settle why they were stale
ebt55 Jul 30, 2026
3b53e4e
env: a file that is hashed needs one spelling -- pin fonts.conf to LF
ebt55 Jul 30, 2026
e5ab9aa
api: a failed conversion must not destroy the file it was replacing
ebt55 Jul 30, 2026
3278feb
api: separate how the DOCX is written from what renders it
ebt55 Jul 30, 2026
5fef21c
testkit: a test that converts nothing is not a proof, and neither is …
ebt55 Jul 30, 2026
0bfcdb9
docs: say what this is for -- Google Docs and the licence -- and what…
ebt55 Aug 1, 2026
92a6f5d
docs: the badge is red on purpose, and the README now says which 12 a…
ebt55 Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,17 @@
# Same reasoning for anything a Linux runner executes or parses strictly.
*.yml text eol=lf
*.yaml text eol=lf

# scripts/fonts.conf is HASHED into the canonical environment fingerprint
# (testkit/evidence.py), so its bytes are a gated value rather than just
# configuration. Authored on Windows it got CRLF, and the recorded digest
# 924510e8... was then unreproducible on any Linux checkout, which computes
# 84d4357a... -- so the canonical reference was invalid on CI and on a fresh
# clone, the two places it has to work. Anything whose bytes reach a fingerprint
# must have exactly one spelling.
*.conf text eol=lf

# The frozen corpus fixtures are byte-exact inputs pinned by SHA-256. Any line
# ending translation would change their hashes and make every gate run fail on a
# corpus identity check -- which is precisely the check they exist to satisfy.
testkit/fixtures/*.pdf binary
98 changes: 98 additions & 0 deletions .github/workflows/gate-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Build and publish the canonical measurement image.
#
# Manual only. Publishing a new image is not a routine event: the fidelity
# numbers are a property of the renderer inside it, so a new digest is a new
# environment, and adopting one is a deliberate baseline migration -- never a
# side effect of a push. See docker/gate.Dockerfile and plan §17 rule 2.
#
# The output that matters is the DIGEST. Pin it in gate.yml; do not reference the
# image by tag, because a tag is a moving reference and that is the whole problem
# this image exists to solve.
#
# After publishing, the canonical reference has to be re-recorded INSIDE the new
# image and the parity floors remeasured against it:
#
# docker run --rm -v "$PWD:/work" -w /work \
# -e FONTCONFIG_FILE=/work/scripts/fonts.conf \
# ghcr.io/ebt55/exactdoc-gate@sha256:<digest> \
# bash -lc 'bash scripts/bootstrap.sh --strict \
# && python testkit/evidence.py --record-canonical --force'
#
# TODO(CI-01): pin every action by commit SHA rather than by tag, alongside the
# same change to gate.yml. Tracked in the plan's CI-01, which hardens all
# workflows together; doing it here alone would leave the repository half-pinned
# and imply a guarantee the other workflow does not give.
name: gate-image

on:
workflow_dispatch:
inputs:
tag:
description: "Extra tag to apply alongside the digest (e.g. 2026-07-30)"
required: false
default: ""

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # least privilege: enough to push, nothing more
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
file: docker/gate.Dockerfile
push: true
# No `latest`. A floating tag on this image would invite exactly the
# drift the image is meant to remove.
tags: |
ghcr.io/${{ github.repository_owner }}/exactdoc-gate:${{ github.sha }}
${{ inputs.tag != '' && format('ghcr.io/{0}/exactdoc-gate:{1}', github.repository_owner, inputs.tag) || '' }}
provenance: true
sbom: true

- name: Report the toolchain this image resolved
run: |
IMG="ghcr.io/${{ github.repository_owner }}/exactdoc-gate@${{ steps.push.outputs.digest }}"
echo "digest: ${{ steps.push.outputs.digest }}"
docker run --rm "$IMG" cat /etc/exactdoc-image.txt

- name: Summary
run: |
{
echo "## Canonical gate image published"
echo
echo '```'
echo "ghcr.io/${{ github.repository_owner }}/exactdoc-gate@${{ steps.push.outputs.digest }}"
echo '```'
echo
echo "Pin this digest in \`.github/workflows/gate.yml\`."
echo
echo "This is a NEW ENVIRONMENT. Before any number measured in it is"
echo "comparable to the record, re-run \`evidence.py --record-canonical"
echo "--force\` inside it and remeasure the parity floors. Adopting a"
echo "digest without that is how a baseline comes to describe an"
echo "environment nobody ran."
echo
echo "### Toolchain"
echo '```'
docker run --rm "ghcr.io/${{ github.repository_owner }}/exactdoc-gate@${{ steps.push.outputs.digest }}" \
cat /etc/exactdoc-image.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
131 changes: 100 additions & 31 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
# The fidelity gate, run on a pinned Linux oracle.
#
# Determinism has two halves and this workflow needed both:
#
# inputs 16 PDFs frozen in testkit/fixtures/, pinned by SHA-256. They
# used to be regenerated here, so a Chromium update on the runner
# silently changed a corpus document and moved a gated metric 5x.
# environment the renderer decides the numbers. `evidence.environment()`
# fingerprints OS, Python minor, LibreOffice, the metric fonts and
# every measurement dependency, and `canonical` means that exact
# combination -- it used to mean `os == "linux"`, which cannot
# tell Chromium 149 from 150 or Python 3.12.3 from 3.12.13.
#
# Why Linux is canonical: the fidelity numbers depend on the renderer's fonts.
# Local Windows runs render with real Arial/Times New Roman; this container
# renders with Liberation metrics-compatible substitutes. Same code, different
# wraps. One environment has to be the reference, and it must be the one
# everyone can reproduce -- so CI is the number of record and local runs are
# indicative.
# Local Windows runs render with real Arial/Times New Roman; this runner renders
# with Liberation metrics-compatible substitutes. Same code, different wraps. One
# environment has to be the reference, and it must be the one everyone can
# reproduce -- so CI is the number of record and local runs are indicative.
#
# Two lanes (testkit/runall.py): `raw` is the uncontaminated converter number,
# `product` is exactdoc.options.PRODUCT -- the profile the API, the CLI and every
# published number all share. refine() tunes against the same renderer the gate
# measures with, so only the pair is meaningful, and **both** lanes gate the exit
# code. Gating on the refined lane alone meant the control lane, whose entire
# purpose is to be untainted, was the one nobody had to answer for.
#
# Two lanes (testkit/runall.py lanes): refine OFF is the uncontaminated
# converter number; refine ON is the product default. refine() tunes against
# the same renderer the gate measures with, so only the pair is meaningful.
# Every step here is fail-closed. That is the whole design: a green check must
# mean that all 16 manifest documents existed, the renderer answered, every
# required metric was computed, nothing regressed past its recorded number, and
# the backend policy still describes reality. It previously could mean none of
# those things -- see the docstrings in testkit/gate.py for the list, each entry
# of which is now a test in tests/test_gate_mutations.py.
#
# Provisioning is scripts/bootstrap.sh, the same command a contributor runs, so
# CI cannot drift away from the documented setup without going red. --strict
# makes a missing oracle a failure here: an incomplete corpus silently produces
# numbers for a different corpus than the one on record.
# makes a missing oracle a failure.
#
# The dependency versions come from uv.lock (--frozen). The goldens are pinned
# to the PyMuPDF version -- measured: 1.26 and 1.24 both put 02_research_paper
Expand All @@ -30,7 +49,7 @@ on:
jobs:
gate:
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: 90
steps:
- uses: actions/checkout@v4

Expand All @@ -46,39 +65,89 @@ jobs:
- name: Provision the oracles (LibreOffice, Chromium, fonts) + deps
run: bash scripts/bootstrap.sh --strict

- name: Generate the 16-document corpus
# Restrict the renderer to the pinned font set for every later step. A
# runner image ships a large font collection; with the corpus already
# frozen byte-for-byte, that alone still moved c4_i18n's dy_p50 from
# 0.15pt to 2.1pt, because LibreOffice resolved its CJK and RTL runs to
# faces the measurement environment does not have. See scripts/fonts.conf.
- name: Pin the font environment
run: |
uv run python testkit/gen_corpus.py testkit/adv
uv run python corpus/make_corpus.py
mkdir -p /tmp/exactdoc-fontconfig
echo "FONTCONFIG_FILE=$GITHUB_WORKSPACE/scripts/fonts.conf" >> "$GITHUB_ENV"
FONTCONFIG_FILE="$GITHUB_WORKSPACE/scripts/fonts.conf" fc-list : family \
| tr ',' '\n' | sort -u


# The metric corpus is 16 PDFs frozen in testkit/fixtures/ and pinned by
# SHA-256. It is NOT regenerated here, and that is the fix for the failure
# this workflow actually had: the baseline was recorded against a corpus
# built with Chromium 149, this runner ships Chromium 150, `c4_i18n` came
# out a different document, and its vertical drift moved 0.15pt -> 0.7pt.
# A gated metric moved 5x because of a browser update. A generated corpus
# cannot be a measurement baseline.
- name: Corpus fixtures - byte-identical to the record?
run: uv run python testkit/corpus_manifest.py verify

- name: Unit tests (write purity, corpus degradation)
# The generators still run, and no longer gate a measured number. Drift
# between a fresh generation and the frozen bytes is reported: it says the
# toolchain moved, which is worth knowing and is not a regression here.
- name: Corpus generators still work (drift reported, not gated)
run: uv run python tests/test_corpus_generation.py

- name: Unit tests (write purity, corpus degradation, gate mutations)
run: |
uv run python tests/test_purity.py
uv run python tests/test_corpus_degradation.py
uv run python tests/test_gate_mutations.py

# The permissive runtime boundary, and the reason the licence flip is a
# real change rather than a metadata edit. This makes `fitz` unimportable
# and then converts the representative fixtures, which is stricter than a
# virtualenv without the package: it also catches an import that something
# else in the interpreter has already performed.
- name: Convert with PyMuPDF made unimportable
run: uv run python tests/test_no_pymupdf.py

- name: Golden IR - the parser's output must not drift
run: uv run python testkit/golden_ir.py verify

- name: Fidelity gate, both lanes
env:
REFINE: lanes
run: uv run python testkit/runall.py testkit/adv corpus/pdfs
- name: Fidelity gate, both lanes, fail closed
run: uv run python testkit/runall.py

# No longer continue-on-error, and it is EXPECTED TO FAIL right now. That
# combination is deliberate.
#
# It was reporting-only "until the swap lands", which made the number it
# exists to drive the one number nothing depended on. The policy now lives
# in testkit/parity_policy.json with numeric floors, so the executable rule
# and the ratified rule are the same rule.
#
# Applying that rule to every dimension independently -- including vertical
# drift, which the old comparison never looked at -- found 2 unwaived
# regressions (05_memo and f1_fpdf_brief, both dy_p50) that had been
# reported as "same". They are attributed to the same core-14 font-metric
# cause as the four D2 waivers and are NOT waived, because widening a
# waiver from four documents to six is a product decision.
#
# Do not re-add continue-on-error to make this green. Red is the correct
# state until the shortfalls are ratified or fixed; going green by ignoring
# the result is precisely how this step stopped working the first time.
- name: Backend parity - the licence-swap verdict
# Reporting-only by design until the swap lands: this is the number M2
# is driving to zero, so a non-zero count is the expected state, not a
# broken build. It runs in CI because it once lost the ability to run
# at all (an undeclared pypdfium2) and nobody noticed for weeks -- a
# gate that cannot run looks exactly like a gate that passes.
continue-on-error: true
run: uv run python testkit/backend_parity.py --refine 3

- name: Upload lane results
run: uv run python testkit/backend_parity.py

- name: Evidence - one artifact every published number traces to
if: always()
run: uv run python testkit/evidence.py --out testkit/batch/evidence.json

- name: Upload lane results and evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: gate-results
path: |
testkit/batch/lane_norefine/results.json
testkit/batch/lane_refine/results.json
if-no-files-found: warn
testkit/batch/evidence.json
testkit/batch/lane_raw/results.json
testkit/batch/lane_raw/verdict.json
testkit/batch/lane_product/results.json
testkit/batch/lane_product/verdict.json
if-no-files-found: error
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ my_samples/
Executor Advisory.md
exactdoc — *.md
*Execution Plan*.md
# The em-dash pattern above missed `exactdoc-production-readiness-plan.md`, which
# is the same kind of document under a hyphen. Match the suffix as well: no
# tracked doc ends in -plan.md.
*-plan.md
Loading
Loading