Imported from khorser/hasquant (
.claude/skills/run-hasquant/SKILL.md). Install upstream withnpx skills add khorser/hasquant --skill run-hasquant. Copyright stays with the author.
hasquant is a library, not a service. Running it means building the shim and Haskell layer, running Hspec, and compiling a standalone smoke program with .claude/skills/run-hasquant/driver.sh when end-to-end binding coverage is needed.
All paths below are relative to the repo root.
Prerequisites
QuantLib 1.43 and GHC/Stack/Cabal are expected to be installed. The GHC 8.10 compatibility gate uses the repository's Docker Compose setup below.
Build
make # C++-only compile check, fast, no Haskell rebuild
stack build --test --no-haddock # full build; prefer through tools/quiet-build.py, see Gotchas
GHC 8.10 gate. The package supports GHC 8.10.6 / base 4.14.3.0, its
declared floor (base >=4.14, set in package.yaml — hasquant.cabal is
hpack-generated, so edit the former and let stack build regenerate the
latter). Nothing merges until this passes:
docker compose run --rm hasquant sh -c 'stack build --resolver lts-18.8 --flag hasquant:buildExample --no-haddock && stack --resolver lts-18.8 test'
(no -it, which fails without a TTY). It catches two things the local
GHC 9.10 build cannot: post-8.10 base functions creeping in — often via
an hlint suggestion, e.g. Data.Functor.unzip (base 4.19+) — and types 9.10
infers but 8.10 rejects (a let-bound helper containing a list literal
under OverloadedLists over-generalised to an IsList-polymorphic type and
failed with Illegal equational constraint; fix: give the helper an
explicit signature).
Run (agent path)
The driver builds lib:hasquant via cabal, registers it in a global GHC
environment file (not written into the repo), then compiles and runs a
test/smoke/*.hs program against it:
.claude/skills/run-hasquant/driver.sh test/smoke/CheckSabrSmileSection.hs
==> cabal build lib:hasquant
==> cabal install --lib hasquant (registers a global GHC environment file, not in-repo)
==> compiling test/smoke/CheckSabrSmileSection.hs
==> running /tmp/hasquant-smoke-CheckSabrSmileSection
OK ShiftedLognormal strike=0.01 vol 0.4197993819773641
...
SabrInterpolatedSmileSection: OK, calibrated smile reproduces the generating SABR vols
Just build and register the library (no smoke script) with:
.claude/skills/run-hasquant/driver.sh --build-only
There's no existing smoke script for what you're checking? Write one in
test/smoke/ (see any file there for the pattern: import the relevant
QuantLib.* modules, construct objects, assert on the results, error on
failure) and pass its path to the driver — that's the whole point of the
harness.
Compiled binaries land at /tmp/hasquant-smoke-<name>; build artifacts at
/tmp/hasquant-smoke-<name>_build/.
Run (human path)
Same idea, spelled out manually (this is what the driver automates):
cabal build lib:hasquant
cabal install --lib hasquant --force-reinstalls # only needed once, or after an API change
cabal exec -- ghc -itest/smoke -package hasquant test/smoke/CheckSabrSmileSection.hs \
-o /tmp/checksabr -outputdir /tmp/checksabr_build
/tmp/checksabr
Test
stack test --ta '--skip LONG' # fast path: ~3s, skips tests marked (LONG)
stack build --test --no-haddock # full suite: ~26s, 141 examples
Both pass clean on the current HEAD.
Slow tests are marked by suffixing (LONG) to the it/describe
description. The effective threshold is ~2.5s, not tens of seconds: measure
before labelling, and re-check existing labels (the equity option block was
labelled (LONG) while running in 0.5s).
Coverage
Plain stack test --coverage --ta '--skip LONG' runs, but is close to
useless here: every c2hs-generated binding module (QuantLib.CashFlow,
QuantLib.Instrument.*, QuantLib.Time.Calendar, …) reports 0/0 — not
low coverage, zero instrumentable expressions — even though these
modules contain real monadic marshalling code (`withLeg a1 $ \a1' -> ...
= \res -> ...
), not bareforeign imports. The generated.hscarries{-# LINE n "Foo.chs" #-}pragmas remapping every declaration back to.chssource positions; GHC's HPC pass assigns tick locations respecting those pragmas, then can't reconcile a tick claiming to be inFoo.chs(a preprocessor input, never itself compiled) with the module it's instrumenting, and silently records nothing rather than erroring. Confirmed by hand: stripping theLINEpragmas from one generated module's.hsand recompiling it standalone with-fhpctook its.mix` file from 0 tick entries to 3170.
tools/hpc-coverage.py automates the fix — for every c2hs-generated
module, force a clean rebuild, strip the LINE pragmas from the generated
.hs before GHC compiles it, then run the suite:
python3 tools/hpc-coverage.py # default: --ta '--skip LONG'
python3 tools/hpc-coverage.py --ta '' # pass through other stack test args
It always starts with stack clean hasquant (needs a clean build to
regenerate .chs → .hs output before it can strip anything) and runs two
full library builds, so budget the time of two stack builds plus a test
run — not something to run on every edit. Report locations print at the
end; the useful one is the per-component report for hasquant_test, e.g.:
.stack-work/install/<arch>/<snapshot>/<ghc>/hpc/hasquant/hasquant_test/hpc_index.html
(there's also a hpc/combined/all/hpc_index.html "unified" report, but its
totals don't reconcile with the sum of its own listed per-module rows on
this codebase — something about how stack merges .tix data across
components inflates it; don't trust it as a percentage). .stack-work is
already gitignored, so the report needs no separate cleanup — but note the
generated .hs files under .stack-work now permanently have their LINE
pragmas stripped until the next stack clean/fresh c2hs run, which makes
GHC error locations for anything compiled from them point at the .hs
instead of the .chs in the meantime (irrelevant for a passing build, only
matters if you're mid-debugging a .chs-side compile error when you run
this).
A gcov/--coverage-on-cbits/ route was tried first and abandoned:
GHC's in-process TH interpreter segfaults loading a --coverage-
instrumented .dylib for any module with a real TH splice (i.e. any
$(free1st/free2nd/...) use from QuantLib.Syntax, which is most of
test/example/), and forcing -fexternal-interpreter swaps that for a
"duplicate object code" load error from gcov's global counter symbols
instead. Not revisited unless the Haskell-side HPC route above turns out
insufficient.
Gotchas
-
Do not trust
cabal replorghcinumeric results that cross intocbits/. Known-good pricing calls can return0.0in the interpreter while the compiled test binary is correct. Inspect intermediate values through a temporary trace in a compiledcabal test/cabal runpath, then remove it. -
After a repository change, run one clean warning-visible build and fix every real source warning it reports, including pre-existing warnings. Use
stack clean hasquantfollowed bytools/quiet-build.py stack build --test --no-haddock; an incremental build can hide warnings in untouched modules. The accepted noise is Stack's non-portablecpp-options: -Pnote and the linker's redundant-Uwarning. The helper suppresses only c2hs's generatedForeign.ForeignPtrunused-import block; do not hide real warnings with a module-wide pragma. Fix partial-function warnings with an exhaustivecase, not another incomplete pattern.Run
hlint ., not per-file linting of.chsinputs. Check a hint is type-correct before applying it; for a proven false positive, add a narrow.hlint.yamlexception with a short reason. -
trackAllocationsneeds the built C++ objects deleted, or it silently does nothing. Neithercabal build --flag trackAllocationsnorstack build --flag hasquant:trackAllocationsrecompilescxx-sourceswhen only a flag changes — both reportUp to datewhile producing a library with no tracing in it.touch cbits/*.cppand deletingdist-newstyle/.../build/cbits/*.odid not trigger it; deleting the wholebuild/cbitsdirectory did. Confirm tracing is compiled in before trusting an empty trace:strings <built .o> | grep -c allocated.Trace destination is the
QLTRACK_ALLOCATIONSenv var when set, falling back to the compile-time path the flag bakes in. Pair the result withtools/alloc-summary.py <trace>, which matches allocations to frees by pointer and reports what is still live, grouped by class; it flags over-frees (double free, or freeing through the wrong type) separately from ordinary leaks. Reading the trace correctly is the whole difficulty — the tool got it wrong twice before its first real trace:ret()is the pointer handed to Haskell and pairs withdel().del()traces twice (deletingthendeleted) for one free; counting both reports everything as double-freed.arg()is pass-through, not a lifecycle event.alloc()is ambiguous: inret(new QlYieldTermStructure(alloc(new FlatForward(...))))the alloc'd object goes into ashared_ptrand correctly never has a matching free, but a value type likeDayCounteris alloc'd, returned directly, and is freed later. Same verb, opposite expectation, distinguishable only per pointer.
Don't re-derive this from the raw log; if you change the tool, re-run it against a hand-written trace seeding a leak, a double free, and one of each
alloc()case — a permissive bug here looks exactly like a clean result. -
stack buildandcabal buildare two independent build systems here and don't share installed-package state. The test suite (stack test) and the smoke-script driver (cabal exec -- ghc -package hasquant) go through different toolchains — building withstackdoes not makecabal execsee the new code. Rebuild withcabal build lib:hasquant(the driver's first step) before running a smoke script, even right after astack build. -
cabal install --lib hasquantfails if already registered ("Packages requested to install already exist in environment file") — the driver passes--force-reinstallsto make re-registering after an API change idempotent. -
Don't use
cabal install --lib hasquant --package-env .— that writes a.ghc.environment.*file into the repo root, an untracked stray thatgit statuswill flag. Plaincabal install --lib hasquantregisters a global environment file under~/.ghc/<arch>/environments/defaultinstead, which is what the driver does. -
A stale build can pass tests against old generated code. Editing a C header (e.g.
cbits/qlEnumObjects.h) without touching any.chsfile leavescabal build/stack buildsilently stale: neither tracks that a.chsfile's#included header changed, so the build reports success without re-running c2hs, and tests then pass against the old generated code. Do a clean build if in doubt. This is exactly why the smoke scripts exist and why this driver is the harness to reach for after any enum/header-only change, not juststack test.A compiled build is not proof that generated enum cases actually changed.
test/smoke/holds standalone end-to-end value-level checks for that, run viacabal exec -- ghc -package hasquant test/smoke/Foo.hs -o /tmp/foo && /tmp/foo. Whenever you add an enum-dispatched case (a new currency, calendar, or index variant — see thereconcile-*/add-quantlib-indexskills), add or extend atest/smoke/script that constructs the new case and prints something derived from it, and actually run it. This is what catches the staleness above and any enum/factory-table order mismatch;test/won't, since it doesn't know about cases it was never written to check. -
Never spell a GC nudge by hand:
QuantLib.Settings.collectGarbageis the one exported name for it. It isperformGC >> performGC, with haddock saying plainly thatperformGConly schedules finalizers, so it is a nudge rather than a guarantee. If a site ever needs athreadDelayfor the finalizer thread to actually get scheduled, add it insidecollectGarbageand re-run everything -- do not re-scatter the idiom across call sites, which is exactly the state it was consolidated out of. -
A new hspec test that sets
Settings.evaluationDatemust wrap its body inSettings.keepingSettingsGc, not a manual trailingcollectGarbage.QuantLib.Settings.keepingSettingsGcis abracket-based helper that already runscollectGarbageright before restoring the savedSettingssingleton — on normal completion and on an exception, which a manual trailing call does not cover. Nearly every hspec test that mutates the evaluation date is already wrapped in it (test/hspec/QuantLib/Spec/DatesAndSchedule.hshas dozens of examples); don't add a second, redundantcollectGarbageon top -- nor a mid-body double GC, which is what the three sites intest/hspec/QuantLib/Spec/TermStructure.hsdid beforekeepingSettingsGcitself was strengthened to the double sweep, and which were deleted then. This matters especially for a test anchored to a fixed historical date with a long internal schedule (a term price surface, a piecewise curve with a maturity decades out): without the bracket's GC, a still-aliveLazyObjectfrom that test can crash an unrelated later test once a subsequentSettings.setEvaluationDatecall notifies observers and the old object's now-past termination date tripseffective date ... later than or equal to termination date ...deep in QuantLib.keepingSettingsGc/keepingSettings's own restore-on-exception behavior has a direct regression test intest/hspec/QuantLib/Spec/DatesAndSchedule.hs(describe "settings") — extend it, don't re-derive it, if this ever needs re-verifying. -
A smoke script must not
try/catchonQuantLib.Type.Error. Compiled standalone from the repo root, ghc findsQuantLib/Type.hsas source and recompiles it, so the script'sErroris a different type from the one the installed library throws:trynever matches, and the script dies with the very message it was written to catch — with no type error, since both sides typecheck against their ownError. CatchSomeExceptioninstead (test/smoke/CheckIterativeBootstrap.hsdoes, with the reason inline).