Start with the fast preflight used by GitHub Actions. Install actionlint 1.7.12 and ShellCheck, then run:
python3 scripts/ci_preflight.pyThis checks the CI/native workflow syntax, expressions and shell commands,
source ABI/hash/schema guards, and the verifier's failure-handling tests. A
failure blocks all compilation lanes and retains logs in build-ci-preflight/.
It does not compile the engine or replace any complete verification profile.
Then use the same verification entrypoint as GitHub Actions before publishing:
python3 scripts/ci_verify.py release --build-dir build --jobs 4The command runs source guards, configures and rebuilds every enabled target,
prepares the real historical ABI providers, runs CTest, installs the package,
and builds and runs the find_package consumer. The smoke test compares the
installed library's reported version with VERSION. After a successful build,
a failing test suite does not hide a separate install or package failure.
Release and native profiles also install into a disposable prefix, remove the
source-only header trees, and compile the native public roots and examples;
the preflight source guard rejects those includes before a build.
CI pins Ubuntu 24.04 and macOS 26, the platforms used by the preceding green refactor PR, and selects Python 3.12 explicitly. CMake uses the same interpreter as the verification driver, so its Python checks do not switch to a different system Python. CTest must discover tests; an empty suite is a failure.
| Profile | Build | Additional coverage |
|---|---|---|
release |
Release, tutorial enabled | Standard CI checks behind a row floor (RELEASE_MIN_TESTS; --min-tests N overrides it), installed package, and installed native include-independence proof |
debug |
Debug, tutorial enabled | The same checks without Release optimization |
sanitizers |
Debug, ASan and UBSan | Instrumented library, tests and installed consumer; Linux CI also requires leak detection |
native |
Release, live runner enabled | Parser, journal, transport tests, installed runner help, and installed native include-independence proof |
kernel |
Release, live runner enabled, Pine source layer OFF | The source-free CTest set behind a row floor (KERNEL_MIN_TESTS; --min-tests N overrides it), installed package, and the nm half of the include-independence proof over libpineforge_kernel.a |
By default each profile uses build-ci-<profile>. Keep separate build directories
for different profiles and toolchains. The verifier never deletes a build tree
or replaces an incompatible historical ABI receipt. Use a fresh directory when
its configuration no longer matches.
Run Release, Debug and sanitizer profiles sequentially in one checkout, or use
separate worktrees for parallel runs. Their tutorial targets write shared .so
files under tutorial/, even with separate build directories. Hosted CI jobs
already use separate checkouts.
python3 scripts/ci_verify.py debug --jobs 4
python3 scripts/ci_verify.py sanitizers --jobs 4
python3 scripts/ci_verify.py native --curl-dir /path/to/curl/lib/cmake/CURL \
--require-websocket --jobs 4The sanitizer profile uses the Linux CI ASan/UBSan environment, including
LeakSanitizer, which needs a supporting runtime. The native CI lane builds
checksum-pinned curl with WebSocket support and passes --require-websocket.
That flag turns a transport skip into failure. A local native run using system
curl may report the existing unsupported-WebSocket skip; that is not the required
Linux transport proof.
The kernel profile also gates the CTest row count: tests/CMakeLists.txt
drops every test TU whose include closure reaches pineforge/source/ or
compat/pine/, so a lane whose native witnesses shared a TU with an adapter
twin would leave the kernel-only gate without any failure. KERNEL_MIN_TESTS
in scripts/ci_verify.py pins the expected row count; the ctest-floor
stage fails when fewer rows ran or CTest printed no count it can read. Only
rows whose test ran count: a row CTest skipped or could not start is listed
beside the count (ctest-floor.log, ctestSkipped / ctestNotRun in
ci-summary.json), never counted, so the unsupported-WebSocket skip above is
reported rather than counted, and a row that stops executing fails the floor
too. The release profile
carries the same gate with RELEASE_MIN_TESTS, so a row deleted from the
default build fails too; debug and sanitizers register a subset of the
release rows. Raise the constant when a row lands, and pass --min-tests N to
override it for one run (the flag gates any profile; kernel and release
have a default).
Every compile of an examples/native source keeps assert() live: the
example_* executables (examples/native/CMakeLists.txt) and the live
runner's two MODULE builds of example sources (runner/CMakeLists.txt, driven
through the C ABI by test_native_example_batch and
test_native_example_selected) put -UNDEBUG after the build-type flags. The
examples-assert-live stage of the release, kernel and native profiles
reads compile_commands.json after configure and refuses, before the build, a
compile of an example source that leaves NDEBUG defined, or a database with
no such compile.
After the build, the stale-binaries stage requires libpineforge.a and
libpineforge_kernel.a each to be newer than every file its own compiles
read: the translation units compile_commands.json compiles into it and the
headers they reach through #include inside the source tree or the build tree
(the generated pineforge/version.h). Files no archive
compiles do not count, so an edit under src/source/ does not fail the
kernel profile, whose archives never compile it, and an edit to a header that
only examples or tests include fails no profile. A database without the
archive's compiles fails the stage.
Pass --ccache when ccache is installed. It caches compiler work, not complete
build directories or verification receipts. Compiler, source, header and flag
checks remain in effect. The native curl cache is separately bound to its source
checksum, workflow configuration, compiler/package environment and install path.
Verification explicitly uses PINEFORGE_VERSION_SOURCE=FILE: package MMP/FULL
identity comes from VERSION, while Git revision/dirty observations remain
separate. Git tags and checkout depth cannot change the smoke-test expectation.
Ordinary CMake builds retain AUTO, the existing git-describe-first behavior.
No release tag or VERSION value is rewritten by verification.
The verifier fetches the pinned ABI commits e60e571 (R2), 0e18690
(selected settlement, before exact reversal), c3ed455 (native host v13),
f736676 (native host v14), e7cdf052 (the frozen v15 source-layer base),
and ab9714b (the frozen v16 adapter-lowering base) without tags only when
each object is missing. It builds all six
prepared static libraries with tests disabled, or validates and reuses
their matching prepared receipts under settlement-abi-base/,
settlement-abi-prior/, native-abi-v13/, native-abi-v14/, and
native-abi-v15-frozen/, and native-abi-v16-frozen/. Compiler,
configuration and version-source mismatches refuse reuse without deleting the old evidence.
Each profile needs matching providers; a Mac Release archive cannot replace
a Linux sanitizer build. CTest itself performs no network fetch.
CTest authenticates all six prepared receipts against their actual archive and
header bytes. The executing settlement, script-host, and aggregate controls
use the authenticated host-ab9714b v16 archive plus the live v17 archive:
v16 callers link to v16, v17 callers link to v17, and both cross-epoch
directions must fail at link time with the expected epoch-qualified symbol.
No ABI caller executable is run. The older receipts remain authenticated
historical evidence; they are not presented as a live v13/v14/v15 link matrix.
The ABI guide describes the
actual old/new library pairs and their immutable inputs.
One row does run a caller of the frozen ab9714b archive:
test_l4g_runtime_budget compiles tests/test_l4g_runtime_budget.cpp once
against the authenticated v16 closure and once against the tree, replays the
same 43 008-bar workload through both, and requires the tree's replay to cost
at most 15x the frozen one (scripts/check_runtime_budget.py, A40 rev 5).
The gated sample is each replay's process CPU time (user + system), taken as
the minimum of five interleaved runs per side; the wall-clock time and the
host's one-minute load average are printed beside it as diagnostics only.
Wall clock was the gated quantity until A40 rev 7 (Q9): it counts the time a
leg spent descheduled, and the minimum of five runs recovers an undisturbed
0.03 s baseline far more often than an undisturbed 0.5 s candidate, so the
wall ratio of one unchanged tree rose from 12x on a quiet host to 29-35x at
load average 190 while its CPU ratio stayed at 10x. On a quiet host the two
clocks agree. scripts/test_runtime_budget.py (row
test_l4g_runtime_budget_mutations) pins that the verdict follows CPU time
and still flips when the candidate's CPU cost doubles. Debug builds run the
candidate once for correctness only (--candidate-only). Every Release lane
gates the ratio, the hosted macOS one included: A40 rev 6 had registered that
lane --candidate-only because its wall clock was no stable timing host (one
tree read 12.8x and 17.2x an hour apart), and CPU time removed that reason.
PINEFORGE_RUNTIME_BUDGET_CANDIDATE_ONLY=1 in a Release configure's
environment is the escape back to a correctness sample. .github/workflows/ci.yml
sets it to 0 on every lane, and RuntimeBudgetLanes in
scripts/test_ci_verify.py holds that; the one reason to set it is a runner
whose CPU accounting cannot time the replay, and that reason, with the log
lines that show it, belongs here. In the build (macos-26, Release) job the
row prints runtime budget: candidate=…s ab9714be=…s ratio=…x limit=15.000x (process cpu time; wall …; load average …) and passes at or below 15x. The
hosted macOS runner's CPU ratio has not been recorded yet (A40 rev 5 recorded
its wall-clock ratio, 12.84x best-of-five); Apple Silicon reads 9.9-10.7x CPU
from load average 80 to 532.
CTest writes settlement-abi-receipt.json, script-abi-receipt.json, and
aggregate-abi-receipt.json for the real v16/v17 controls, plus
native-abi-receipt.json for native controls. The native receipt includes the
active v14_current_execution_shape_agnostic_compile, frozen-v16
surface controls, and v17-current rejection controls against authenticated tar
closures. CURRENT_TERMS_SURFACE_READY = True: the complete current-execution,
FX, and missing-Cancelled controls are active, and the good caller compiles
before its intentional negative compile control. Ordinary compile failures
remain failures, separate from ABI link rejections.
Outside ci_verify.py, a build tree configured with
PINEFORGE_REQUIRE_ABI_RECEIPTS=OFF -- the default of every plain configure,
the one in CLAUDE.md included -- registers the four receipt-gated rows
(test_script_cpp_abi, test_settlement_cpp_abi,
test_aggregate_cpp_versions_runtime, test_l4g_runtime_budget)
--skip-if-receipt-missing. Until the six providers are prepared in that tree,
each exits 77, and CTest counts a skipped row as passed: 100% tests passed
does not include them. scripts/check_abi_receipt_skips.py --build-dir <dir>
names every receipt-gated row that will skip (or, registered
--require-receipts, fail) and exits 1 when there is one. With --prepare it
first prepares the six providers into the tree with the argv ci_verify.py
uses (ReceiptRecipe in scripts/test_ci_verify.py holds the two equal):
it fetches a missing pinned commit, reuses a matching provider and refuses a
mismatched one without deleting it, so a tree prepared this way is one
ci_verify.py would reuse. test_l4g_runtime_budget also reads the tree's
compile_commands.json once its receipt exists, so the script names a tree
configured without CMAKE_EXPORT_COMPILE_COMMANDS=ON too. The recipe:
cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DPINEFORGE_BUILD_TESTS=ON
cmake --build build -j4
python3 scripts/check_abi_receipt_skips.py --build-dir build --prepare
ctest --test-dir build --output-on-failure
python3 scripts/check_abi_receipt_skips.py --build-dir build # exits 1 if a gated row skippedThe CTest row test_abi_receipt_skips (scripts/test_abi_receipt_skips.py)
tests the script against the real ctest, including a run CTest reports as
100% tests passed in which gated rows skipped.
Every profile in scripts/ci_verify.py configures
PINEFORGE_BUILD_CORPUS_STRATEGIES=OFF, so no profile above compiles or runs a
single corpus strategy. TradingView parity — the property the validation corpus
exists to prove — is gated by its own workflow,
.github/workflows/corpus-parity.yml,
and by one local command:
./scripts/check_corpus_parity.shIt checks the corpus submodule out at the gitlink this repository records,
refuses to start if a pinned input under validation/ is modified, builds the
runtime and all 312 corpus strategies, re-runs every one, and then applies two
gates:
- Byte-identity. Every
engine_trades.csvmust hash to whatscripts/corpus_parity_baseline.txtpins (scripts/corpus_trades_identity.py). A probe whose hash moved is named with its expected and produced sha256 and the first rows of its file that differ from the pinned corpus tape. - Tiers.
scripts/verify_corpus.py --all --quietmust still print its pinned headline, currentlyVerified 312 strategies — excellent=311, strong=0, moderate=0, weak=0, minimal=0, anomaly=1, engine_only=0, missing=0.
Both always run, so one command reports both. Exit 0 is parity, 1 is drift, 2 is "the check could not run" (no gitlink, wrong corpus commit, modified pinned inputs).
The committed corpus/validation/<probe>/engine_trades.csv look like the
oracle, and for a long time they were. They are not one now: pineforge-corpus
442d497 re-transpiled every generated.cpp against the R4-D codegen without
re-running the tapes, so what it commits is an older harness generation. At
this engine, 0 of 312 tapes reproduce byte-for-byte — the harness appends an
Engine range-end column (run_strategy.py, 11e61d41) and prints Qty at
full precision — and only 98 of 312 still agree on every column the tape does
record. A gate on those bytes would fail on every commit and prove nothing.
scripts/corpus_parity_baseline.txt is therefore the byte oracle: the sha256
of every engine_trades.csv this engine produces at the recorded corpus
gitlink, refreshed only by a deliberate
./scripts/check_corpus_parity.sh
python3 scripts/corpus_trades_identity.py --updatein a commit that carries the evidence. Two invariants are still taken from the
tapes, because those cannot go stale: every probe the corpus commits must
produce a file, and its header must be the tape's header, optionally plus a
column declared in ALLOWED_EXTRA_COLUMNS. An undeclared column is drift.
The two gates are not redundant. A one-tick slippage change on a single probe
moves its trades and its baseline hash while verify_corpus.py still reports
excellent=311, strong=0, anomaly=1: the tier verifier grades against the
TradingView tape with tolerances, so it cannot see a regression that stays
inside them. The baseline can.
| Variable | Default | Meaning |
|---|---|---|
BUILD_DIR |
build-corpus-parity |
CMake build directory |
JOBS |
nproc (else 4) |
parallel build/run jobs |
DIFF_FILES |
5 |
drifted probes expanded |
DIFF_LINES |
20 |
lines printed per drifted probe |
EXPECTED_VERIFY |
the headline above | the pinned verifier line |
BUILD_TYPE |
Release |
CMake build type |
SUBSET_FILE |
scripts/corpus_parity_subset.txt |
the probe list --subset reads |
SKIP_BUILD |
0 |
reuse an existing BUILD_DIR (developer loop only) |
SKIP_RUN |
0 |
judge the trades already on disk (developer loop only) |
Measured end to end on a 16-core laptop: configure 3 s, runtime library 15 s,
all 312 corpus strategies 90 s at JOBS=8 — and then the run phase, which
scripts/run_corpus.sh executes serially, one run_strategy.py ctypes
process per probe over a 221 861-bar feed, took 1792 s (about 5.7 s per
probe, machine load average 100–160 from sibling builds). Judging the result
(byte-identity plus the verifier) takes a further 94 s. That is ~32 min here,
and the dominant term is single-core serial work that a 4-vCPU hosted runner
runs slower, not faster.
So the whole population cannot be held to the ~25 min a required pull-request
check is budgeted for, and it runs nightly (01:30 UTC), on workflow_dispatch,
and on pull requests that touch the corpus pin or the parity tooling. ccache
(2 GB, keyed per commit with a prefix restore) makes a re-run at an unchanged
pin almost entirely cache hits; the Git-LFS chart feed (~176 MB, one
full-history 1m CSV) is pulled once per job.
A nightly gate cannot stop a merge: a src/** change that moves a TradingView
trade merges green and is caught the next night at the earliest. The blocking
half is a named population, not a weaker oracle:
./scripts/check_corpus_parity.sh --subsetbuilds and re-runs only the 30 probes
scripts/corpus_parity_subset.txt
names — in parallel, which the full sweep's serial loop is not, because the
probes write into disjoint directories and share only the read-only feeds — and
judges them against the same pinned sha256 rows of
scripts/corpus_parity_baseline.txt. There is no subset baseline;
corpus_trades_identity.py --update refuses --subset, so the fast gate can
never re-record the oracle it is measured by, and a row naming a probe the
corpus does not commit is exit 2, never a pass.
The 30 were chosen mechanically, and the reason each one is in the list is on
its line. Every strategy.pine was scanned for the 40 engine mechanisms the
corpus exercises at all — order kinds, exit shapes, OCA, pyramiding, the three
commission kinds, the three sizing bases, slippage, margin, the three
calculation-timing switches, request.security and _lower_tf, the magnifier,
sessions, calendars, timeframes, risk rules and the
ta/math/array/matrix/map/UDT/drawing surfaces — a greedy cover took the fewest
probes witnessing all 40, then one probe per corpus family of three or more
members the cover had missed, then the keepers: the corpus's only anomaly-tier
verdict, its two largest trade surfaces, and the mechanisms the campaign
measured as divergence-prone. Every mechanism with exactly one witness in the
corpus is therefore in the subset by construction — strategy.cancel_all,
calc_on_order_fills=true, commission.cash_per_order, a non-zero
commission_value, and map.*.
Measured on the same 16-core laptop at JOBS=8, corpus 442d497, under sibling
load: derive 2 s (a no-op when the feeds are fresh), build the runtime and the
30 strategy .so 68 s from clean, run 45 s wall for 80 s of probe CPU,
judge <1 s — 94 s end to end over an up-to-date build directory, against
1792 s for the run phase alone in full mode.
corpus-parity.yml exposes it as a reusable workflow (mode: subset) and
ci.yml calls it on every CI run and lists it in build-gate's needs. The
"PineForge strict CI base" ruleset requires the contexts build and
sanitizers; build is build-gate, so the parity subset blocks a merge
through the context that is already required, with no ruleset change. A
skipped dependency is not a success, so the job is deliberately unfiltered by
path.
What the subset does not prove, and what therefore stays with the nightly
sweep: the other 282 probes, and the tier headline —
scripts/verify_corpus.py grades the whole population, so 30 re-runs cannot
print its line, and grading the 282 untouched tapes beside them would judge the
corpus's own older generation rather than this engine. The subset is a blocking
floor, not a replacement.
The subset job checks the submodule out with GIT_LFS_SKIP_SMUDGE=1 and
restores corpus/data from a cache keyed by the gitlink, so the ~176 MB feed
is fetched at most once per pin rather than once per push; the restored bytes
are then checked against the sha256 in the corpus's own Git-LFS pointer, so a
stale or corrupt cache fails as "the feed is wrong" and not as "30 probes
drifted".
Four guards hold the published documentation to the tree. Three of them run
from the source alone — no build, no corpus — and are ci_preflight stages;
the fourth is the documentation build.
scripts/check_doc_anchors.py checks every file:line citation the pages
make: the file must resolve, the line must exist, and when a backticked symbol
precedes the citation in the same table cell or sentence, that symbol must be
on the cited line. Anchors rot on every header edit — a single campaign left
474 of 992 wrong — so the gate is the only durable repair. --list dumps every
anchor with its verdict; --fix re-anchors in place, and it changes line
numbers and nothing else: it moves a citation only when the claimed symbol
has exactly one occurrence in the resolved file's code, or exactly one type
declaration there. It will not choose between several plausible lines, will not
invent a line for a symbol that has vanished, and will not guess a file — those
are edits to the sentence, not to the coordinate, and it reports each one with
its candidate lines instead.
python3 scripts/check_doc_anchors.py # the drift report, exit 1 if any
python3 scripts/check_doc_anchors.py --list # every anchor and its verdict
python3 scripts/check_doc_anchors.py --fix # re-anchor what is provablescripts/check_doc_lint.py reads the prose instead: a lane L<n> roadmap
label on a published page, a there is no … yet / in this slice / today claim
whose line carries no <!-- verified HEAD --> marker, a stale epoch or hash
domain, and a relative markdown link whose file or #anchor is missing. The
marker on the line above is the escape hatch itself: this sentence names the
pattern rather than claiming it, and the guard has no way to tell those apart,
so somebody has to say so where the diff will show it. The
live epoch set is read out of the tree — every inline namespace <family>_v<n>
and every "pineforge-…/v<n>" domain — so the rule needs no edit when an epoch
bumps and does not flag a live epoch of a family whose other members moved on.
python3 scripts/check_doc_lint.pyBoth guards are wired into scripts/ci_preflight.py as doc-anchors and
doc-lint, and both are binding: a bad anchor or a stale sentence fails
the preflight, like any other stage. They ran advisory for one integration
window — the citations and the sentences they named were exactly what the two
page-rewriting lanes were written to correct, and a red stage on the
integration branch would have blocked every other lane's verification — and the
--strict-docs flag that promoted them was removed with that state: the pages
are clean, and a guard that can be asked not to bind is decoration. The
advisory mechanism remains in ci_preflight.py for the next guard that lands
before the drift it names is gone; no stage uses it today.
python3 scripts/ci_preflight.py # doc-anchors and doc-lint among its stagesTheir own self-tests, doc-anchors-tests and doc-lint-tests, were never
advisory, and both register as CTest rows (test_doc_anchors, test_doc_lint).
The third source-only guard is scripts/check_design_inventory.py
(design-inventory, with its own design-inventory-tests suite). It was
fail-closed from the day it landed: it reads docs/design/native-feature-parity.md
§1, replays tests/CMakeLists.txt's own decision about which units the kernel
profile compiles, and fails when an inventory row's closure marker does not
match what the tests actually drive. --ctest-list cross-checks its transliteration
against real ctest -N output in both directions.
The fourth guard is the documentation build. docs/build.sh runs Doxygen over
the whole public surface — every header under include/pineforge, the C API
and C ABI headers, the fifteen native examples, the adapter units, the pages,
the front-door documents, the design document and the ADR — and then reads docs/site/doxygen-warnings.log.
Doxygen's WARN_AS_ERROR is global, which would let a stale comment in a
legacy adapter header stop the site from building, so the gate is scoped in the
script instead: a warning in the guarded set — native_host.hpp,
native_run_spec.hpp, native_order.hpp, native_order_identity.hpp,
native_toolkit.hpp, native_c_api.h, pineforge.h, docs/groups.dox,
examples/native/ — fails the build; anything else is printed and counted. The
count is zero today.
bash docs/build.sh # docs/site/html/index.html, exit 1 on a guarded warningCI pins Doxygen 1.13.2 (.github/workflows/docs.yml); the gate is a grep over
the warning log rather than a Doxygen setting, so it behaves the same on any
version. One version-specific note: 1.18 aborts when its configuration arrives
on stdin, so build.sh writes a temporary config file.
Each build directory contains ci-summary.json and full command logs under
ci-logs/, plus CTest JUnit when the installed CTest supports it. A version
failure records both actual and expected values. GitHub Actions retains compact
diagnostics, CTest logs and ABI receipts and publishes the stage results in its
job summary. Native curl configure/build/install logs and dependency environment
identity are retained even if preparation fails before the verifier starts.
Compiler objects, binaries and dependency caches are not uploaded
as diagnostics.
Superseded pull-request runs are canceled. CI/native main/post-merge and manual proof runs
use distinct concurrency groups and remain uncanceled. Native verification is a
reusable workflow called once by CI, with a separate concurrency namespace; it
also supports manual dispatch. The required build check passes only when
preflight, all four standard builds, sanitizers, native verification and the
TradingView parity subset succeed. A failed, canceled or skipped dependency
cannot produce a green build check.
The separate required sanitizers status remains available.
These checks do not run the parity campaign. Fixed-population Cloud measurement, the actual gate and post-merge evidence remain separate acceptance steps.