diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6a4fc1f..4e0b467 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -31,6 +31,7 @@ uv run ruff check . uv run ruff format --check src tests examples tools uv run qf bench uv run qf audit +uv build ``` ## Adding an adapter @@ -40,3 +41,14 @@ Implement `catalog`, `read_series`, `tables`, `read_table`, `invariants` and against it — see `tests/test_duckdb_adapter.py` for the pattern. An adapter that cannot serve `pub_date` per observation cannot support point-in-time and should say so in its docstring rather than pretending. + +Run `qf adapter-check --json adapter-conformance.json` before writing bespoke +integration tests. Passing the suite is necessary, not proof that a data source +is accurate or licensed. + +## Claims and operating evidence + +Do not describe repository functionality as expert accuracy, production safety +or user value. Changes to `benchmarks/quality-evidence.json` require an immutable +raw artifact, measurement window, denominator, sample size and named approver. +See `docs/acceptance.md` and `ROADMAP.md` before proposing a maturity claim. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5bc7bd4..2fb2981 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,6 +9,8 @@ - [ ] `uv run ruff format --check src tests examples tools` passes - [ ] `uv run qf audit` still reports evidence and gaps accurately - [ ] no credentials, licensed data or workspace directories committed +- [ ] maturity and security language does not overstate what the evidence proves +- [ ] generated site and evidence/package examples still reproduce when relevant ## If this touches contracts or the harness - [ ] the property it guarantees is stated in the test name diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 4804006..cd66a08 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -25,3 +25,17 @@ treat the workspace directory as untrusted output. The core ships no credentials and no data. Adapters read theirs from the environment. Never commit an API key, a database path containing licensed data, or a workspace directory; `.gitignore` covers the defaults and CI scans commits. + +## Evidence-package integrity + +`qf verify` checks internal hashes and manifests. It is not publisher +authentication: anyone able to replace an entire package can recompute its +self-hash. A production deployment should sign package identities or publish +them through a trusted transparency registry. + +## Supported versions + +Quantifact is pre-1.0 alpha software. Security fixes are applied to the latest +commit on `main`; older source snapshots are not maintained. There is currently +no production-safe release because process/container isolation is not yet part +of the core runtime. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c7e7497 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: uv + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + labels: [dependencies] + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + labels: [dependencies] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b243894..dce2393 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: git diff --exit-code -- site/index.html - name: Point-in-time example run: echo n | uv run python examples/04_point_in_time/run.py + - name: Adapter conformance + run: uv run qf adapter-check --json /tmp/adapter-conformance.json + - name: Sliced research evaluations + run: uv run qf evals --dir benchmarks --json /tmp/research-evals.json build: runs-on: ubuntu-latest @@ -50,8 +54,45 @@ jobs: uv venv --python 3.12 /tmp/qf-wheel-test uv pip install --python /tmp/qf-wheel-test/bin/python dist/quantifact-*.whl cd /tmp + /tmp/qf-wheel-test/bin/qf --workspace /tmp/qf-wheel-run ask \ + --out /tmp/qf-wheel-report.html \ + --evidence /tmp/qf-wheel-evidence.json + /tmp/qf-wheel-test/bin/qf verify /tmp/qf-wheel-evidence.json + /tmp/qf-wheel-test/bin/qf adapter-check --json /tmp/qf-wheel-adapter.json /tmp/qf-wheel-test/bin/qf audit --json /tmp/qf-wheel-audit.json + set +e + refusal=$(/tmp/qf-wheel-test/bin/qf plan "Build a DCF valuation" 2>&1) + status=$? + set -e + test "$status" -eq 2 + test "$(printf '%s\n' "$refusal" | wc -l)" -eq 1 + printf '%s\n' "$refusal" | grep '^refused: ' - uses: actions/upload-artifact@v4 with: name: dist path: dist/ + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check local Markdown links + run: | + python - <<'PY' + from pathlib import Path + import re + + roots = [Path("."), Path("docs"), Path(".github"), Path("examples"), Path("benchmarks")] + files = sorted({p for root in roots for p in root.rglob("*.md") if ".git" not in p.parts}) + missing = [] + for path in files: + for target in re.findall(r"\[[^]]*\]\(([^)]+)\)", path.read_text()): + target = target.split("#", 1)[0] + if not target or "://" in target or target.startswith("mailto:"): + continue + if not (path.parent / target).resolve().exists(): + missing.append(f"{path}: {target}") + if missing: + raise SystemExit("broken local links:\n" + "\n".join(missing)) + print(f"checked {len(files)} Markdown files") + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 379aec9..9b7e4c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,25 +4,93 @@ on: push: tags: ["v*"] -permissions: - contents: read - id-token: write # PyPI trusted publishing - jobs: - publish: + validate-build: runs-on: ubuntu-latest - environment: pypi + permissions: + contents: read + id-token: write + attestations: write + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --extra duckdb + - name: Validate release identity + id: version + run: | + uv run python tools/check_release.py --tag "$GITHUB_REF_NAME" + version=$(uv run python tools/check_release.py --print-version) + echo "version=$version" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ (a|b|rc|dev)[0-9]+ ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - run: uv run ruff check . + - run: uv run ruff format --check . - run: uv run pytest -q - - name: Require audited operating evidence - run: test -f benchmarks/quality-evidence.json - - name: PAT-level release gate - run: uv run qf audit --evidence benchmarks/quality-evidence.json --strict + - run: uv run qf evals --dir benchmarks --json /tmp/research-evals.json + - run: uv run qf adapter-check --json /tmp/adapter-conformance.json + - name: Stable/PAT-level operating-evidence gate + if: steps.version.outputs.prerelease == 'false' + run: | + test -f benchmarks/quality-evidence.json + uv run qf audit --evidence benchmarks/quality-evidence.json --strict - name: Build clean distributions + run: uv build + - name: Smoke-test installed wheel + run: | + uv venv --python 3.12 /tmp/qf-release-test + uv pip install --python /tmp/qf-release-test/bin/python dist/quantifact-*.whl + cd /tmp + /tmp/qf-release-test/bin/qf --workspace /tmp/qf-release-run ask \ + --out /tmp/qf-release-report.html \ + --evidence /tmp/qf-release-evidence.json + /tmp/qf-release-test/bin/qf verify /tmp/qf-release-evidence.json + - uses: actions/attest-build-provenance@v2 + with: + subject-path: "dist/*" + - uses: actions/upload-artifact@v4 + with: + name: release-dist + path: dist/ + + github-release: + needs: validate-build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + PRERELEASE: ${{ needs.validate-build.outputs.prerelease }} + VERSION: ${{ needs.validate-build.outputs.version }} run: | - rm -rf dist - uv build + args=(--repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes \ + --title "Quantifact $VERSION") + if [[ "$PRERELEASE" == "true" ]]; then + args+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" dist/* "${args[@]}" + + publish-pypi: + if: needs.validate-build.outputs.prerelease == 'false' + needs: validate-build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-dist + path: dist - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b273d..07c5ea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ All notable changes to this project are documented here, following ## [Unreleased] +## [0.3.0a1] — 2026-08-13 + +This alpha release strengthens the repository's fail-closed research and +distribution boundaries. It does not claim expert validation, production +isolation, or investment fitness. + +### Added +- Versioned `ResearchEvidencePackage` with plan, code, source-vintage manifest, + claim lineage, value fingerprints, admission semantics and integrity checks. +- Offline `qf verify` command with explicit integrity/authenticity boundary. +- Registered event-study and historical-analogy method contracts. +- Product operating model, acceptance protocol, evidence-package ADR and public + maturity roadmap. +- Visible-vintage fingerprint tests and memoisation. +- Fail-closed rule-planner routing for unsupported research families. +- Family/risk/severity-aware evaluation reports with refusal cases and a + separate silent-critical-failure count. +- Public adapter PIT conformance suite and `qf adapter-check`. +- Optional disposable-process execution with wall/CPU/memory containment and + explicit non-sandbox semantics. + +### Changed +- Successful report runs emit an evidence package beside the HTML report. +- The quality audit requires the evidence package to verify before awarding + full diagnosability credit. +- Cache input identity now hashes only the data visible at the knowledge date. + ## [0.2.0] — 2026-08-12 ### Added diff --git a/CITATION.cff b/CITATION.cff index 987b0b4..0823b88 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: website: "https://github.com/leoncuhk" repository-code: "https://github.com/leoncuhk/quantifact" license: Apache-2.0 -version: 0.2.0 -date-released: 2026-08-12 +version: 0.3.0a1 +date-released: 2026-08-13 keywords: - llm agents - investment research diff --git a/README.md b/README.md index 24a777e..924c32f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-3776AB.svg)](pyproject.toml) [![License Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-6B7280.svg)](LICENSE) [![Explore a run](https://img.shields.io/badge/explore-a_live_run-14B8A6.svg)](https://leoncuhk.github.io/quantifact/) +[![Status: alpha](https://img.shields.io/badge/status-alpha-f59e0b.svg)](#project-status) Quantifact is an open-source investment-research system built as an evidence compiler. Four bounded subsystems turn an ambiguous question into a typed @@ -42,6 +43,12 @@ A successful run produces more than a chart: timings, findings, and output lineage; - content-addressed results, so changing one task recomputes only what changed. +The durable output is a versioned **Research Evidence Package**, not the chat +or HTML report. It binds each permitted claim to its plan, source vintages and +licences, generated code, materialised-value fingerprint and verdicts, then +protects the package with an integrity hash. Admission means only “fit for +expert review”; it never means investment approval or proof that a claim is true. + If a required check fails, the run stops or repairs the named task. It does not quietly turn an unverified number into a polished report. @@ -53,12 +60,14 @@ or market-data licence required. ```bash uv add git+https://github.com/leoncuhk/quantifact qf ask --receipt .qf/run.json +qf verify .qf/report.evidence.json +qf ask --execution process --task-timeout 10 # crash/timeout containment, not a sandbox ``` ```text as_of 2026-08-01 (nothing published later was read) plan 16 tasks in 5 layers -contracts 68/68 verdicts passed +contracts 70/70 verdicts passed report .qf/report.html receipt .qf/run.json ``` @@ -101,7 +110,17 @@ Quantifact makes those failure modes explicit: | Expensive iteration | Content-addressed caching recomputes only the affected subgraph | | Unsafe learning | A lesson must reproduce a failure, fix it, and pass regression before acceptance | -## Four bounded subsystems +## Product value and buyer + +Quantifact is intended for research organisations where analyst time, review +cost, data semantics and silent error are expensive. Analysts use it; portfolio +managers challenge its evidence; Heads of Research/CIOs buy research capacity; +risk and compliance buy reconstruction and controls; data leaders buy a common +execution layer. The measurable objective is shorter time to reviewable evidence, +lower reviewer effort, fewer escaped critical errors and more reusable workflows—not +more generated reports. See [the product and operating model](docs/product-operating-model.md). + +## Four subsystems that contain investment-research error The public PAT presentation is best understood as four cooperating subsystems, not four Python modules. Quantifact implements the same separation because each @@ -206,6 +225,13 @@ controls, cache, receipts, and packaged examples are executable and tested. Production isolation, broad expert evaluation, service reliability, and user outcomes still require operating evidence. +The current repository-only audit is **41.5/100 — concept prototype**. This is +not a popularity score: it deliberately assigns zero to outcomes a repository +cannot prove. Compiler, PIT, contracts, evidence packages and reproducibility +are executable; expert-held-out accuracy, licensed-data breadth, isolation, +service SLOs and adoption remain open gates. See the [maturity matrix and +roadmap](ROADMAP.md). + [`qf audit`](docs/quality-model.md) makes that boundary measurable and the release workflow fails closed when required evidence is absent. See [Production guidance](docs/guides/production.md) before connecting an untrusted @@ -236,6 +262,9 @@ one of the most valuable contributions this project can receive. - [Architecture decisions](docs/adr/) - [Interactive run explorer](https://leoncuhk.github.io/quantifact/) - [Quality model and delivery gates](docs/quality-model.md) +- [Product and operating model](docs/product-operating-model.md) +- [Highest-quality acceptance protocol](docs/acceptance.md) +- [Maturity matrix and roadmap](ROADMAP.md) ## Acknowledgements diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..bd1dfe3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,70 @@ +# Maturity and roadmap + +Quantifact is a high-quality **alpha architecture prototype**, not yet a +production investment-research operating system. This distinction is a product +control: repository features cannot substitute for expert accuracy, operating +reliability or adoption evidence. + +## Evidence snapshot + +| Dimension | Current evidence | Status | Exit evidence | +|---|---|---|---| +| Typed research compiler | Executable plan IR, actual-DAG cross-check and property tests | Implemented for supported ops | Held-out plans across 6–10 research families | +| Point-in-time | Bitemporal demo store, PIT-bound loaders, survivorship tests and visible-slice cache identity | Implemented prototype | Licensed vintage data and adversarial adapter conformance | +| Evidence admission | Method, claim, schema, invariant, PIT and review gates; verifiable evidence package | Implemented prototype | ≥95% expert acceptance and zero known silent critical errors | +| Research breadth | Rule-complete oil-event workflow; model planner stub-tested | Limited | ≥50 expert-audited heterogeneous questions, ≥90% plan acceptance | +| Data and tools | Synthetic series/documents and local DuckDB adapter | Limited | Licensed corpora and ≥80% sampled human-workflow tool coverage | +| Execution security | AST policy and restricted namespace | Not production-safe | No-network, resource-limited sandbox and escape evaluation | +| Learning | Failure → benchmark → candidate lesson → regression → human review | Minimum loop | General effects and ≥80% held-out lift without regression | +| Service | CLI and offline package | Not a service | Cancellable/resumable operation and ≥99.5% measured success | +| User value | Product metrics and protocol defined | Unmeasured | Named cohort, ≥60% weekly retention, ≥50% median time saved | + +Run `qf audit` for the live machine-readable assessment. As of 2026-08-13 the +repository-only result is **41.5/100 — concept prototype** with seven operating +evidence blockers. The rule planner now refuses unsupported families, public +evaluations report family/risk slices, and the demo adapter passes 8/8 protocol +checks; none substitutes for real breadth or licensed-data evidence. Scores are +not manually promoted. + +## Delivery sequence + +### 1. Prove one research family + +- Convene 2–3 accountable domain reviewers. +- Build at least 50 held-out event-study and historical-analogy cases. +- Include refusal, revision, date-boundary, universe, unit and selection traps. +- Publish the rubric, denominators, reviewer disagreement and raw outcomes. + +### 2. Prove real point-in-time data + +- Connect one licensed, revision-aware adapter and document corpus. +- Add observation, availability, revision and effective-time conformance tests. +- Validate claim-to-source lineage under real entitlements and licences. + +### 3. Isolate execution + +- Move generated code into no-network, read-only, resource-limited workers. +- Replace per-task process spawn with a pre-warmed isolated worker pool. +- Add cancellation, checkpoints and resumability. +- Run sandbox escape, prompt-injection and entitlement adversarial suites. + +### 4. Demonstrate operating and user outcomes + +- Pilot with a named expert cohort and a pre-registered baseline. +- Measure serious-error escape rate separately from refusal/run failure. +- Measure time to reviewable evidence, reviewer effort, retention and reuse. +- Supply immutable evidence to `qf audit --strict` only after approval. + +### 5. Release maturity + +- Protect `main` with CI, secret scan and review requirements. +- Enable dependency alerts and automated dependency updates. +- Publish signed releases, attestations and a trusted evidence-package registry. +- Claim PAT-level evidence only when every strict operating gate passes. + +## Contribution priorities + +The highest-value contributions are counterexamples and evidence: a missed +failure, an expert-authored held-out plan, a correctly versioned adapter, a +research-family contract or a reproducible isolation test. Broad feature +requests without a failure or evaluation case are intentionally lower priority. diff --git a/benchmarks/README.md b/benchmarks/README.md index 51c9282..126b902 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,12 +1,13 @@ # Benchmarks -Two kinds, deliberately kept apart. +Three kinds, deliberately kept apart. ## Behaviour — assertions over what the planner produces ```bash qf evals --dir benchmarks/plan qf evals --dir benchmarks/contract +qf evals --dir benchmarks/refusal --json refusal-results.json ``` These are the public subset: enough to show the shape and to regression-test the @@ -16,6 +17,12 @@ planner. `qf teach` writes new ones into your workspace as it learns. |---|---| | `plan/plan-compiles.json` | the demo question yields a plan that compiles, with the expected structure | | `contract/binds-the-rate-not-the-index.json` | three trap series that match the query text are considered and rejected | +| `refusal/*.json` | unsupported research families fail closed instead of receiving an oil-event plan | + +Every case names a research `family`, `risk_tags`, `expected_outcome` and +`severity`. Reports are sliced by family and risk and count silent critical +failures separately. A growing total pass rate is meaningless if all new cases +come from the same easy family. Assertion types: `plan_has_task`, `plan_lacks_task`, `chart_has_facet`, `task_count_min`, `plan_valid`, `series_bound`, `series_rejected`. diff --git a/benchmarks/contract/binds-the-rate-not-the-index.json b/benchmarks/contract/binds-the-rate-not-the-index.json index 70fccaa..f6bd6c9 100644 --- a/benchmarks/contract/binds-the-rate-not-the-index.json +++ b/benchmarks/contract/binds-the-rate-not-the-index.json @@ -4,6 +4,10 @@ "kind": "plan", "origin": "manual", "human_audited": true, + "family": "historical_analogy", + "risk_tags": ["wrong_unit", "wrong_frequency", "wrong_scale"], + "expected_outcome": "plan", + "severity": "critical", "note": "Three traps share the query text: an index, the same rate in basis points, and a quarterly twin. Only the value prior and the frequency check separate them.", "assertions": [ {"type": "series_bound", "requirement": "us_headline_cpi", "series_id": "US.CPI.HEADLINE.YOY"}, diff --git a/benchmarks/plan/plan-compiles.json b/benchmarks/plan/plan-compiles.json index 22398c3..8ddd0ec 100644 --- a/benchmarks/plan/plan-compiles.json +++ b/benchmarks/plan/plan-compiles.json @@ -4,6 +4,10 @@ "kind": "plan", "origin": "manual", "human_audited": true, + "family": "event_study", + "risk_tags": ["plan_structure", "method_contract"], + "expected_outcome": "plan", + "severity": "critical", "note": "The baseline: the demo question must produce a plan that compiles, with the full ingestion/logic/chart structure.", "assertions": [ {"type": "plan_valid"}, diff --git a/benchmarks/refusal/unsupported-equity-valuation.json b/benchmarks/refusal/unsupported-equity-valuation.json new file mode 100644 index 0000000..8d0f81f --- /dev/null +++ b/benchmarks/refusal/unsupported-equity-valuation.json @@ -0,0 +1,14 @@ +{ + "id": "refuse-unsupported-equity-valuation", + "prompt": "Build a discounted cash-flow valuation for a semiconductor company.", + "kind": "refusal", + "origin": "manual", + "human_audited": true, + "family": "equity_fundamental", + "risk_tags": ["unsupported_family", "wrong_workflow"], + "expected_outcome": "refuse", + "severity": "critical", + "note": "The exact rule planner must not disguise its oil-event workflow as an equity valuation.", + "assertions": [], + "answers": {} +} diff --git a/benchmarks/refusal/unsupported-portfolio-optimization.json b/benchmarks/refusal/unsupported-portfolio-optimization.json new file mode 100644 index 0000000..897a19f --- /dev/null +++ b/benchmarks/refusal/unsupported-portfolio-optimization.json @@ -0,0 +1,14 @@ +{ + "id": "refuse-unsupported-portfolio-optimization", + "prompt": "Optimize a multi-asset portfolio subject to a 10 percent volatility target.", + "kind": "refusal", + "origin": "manual", + "human_audited": true, + "family": "portfolio_construction", + "risk_tags": ["unsupported_family", "wrong_workflow"], + "expected_outcome": "refuse", + "severity": "critical", + "note": "No portfolio optimizer or risk model is registered in the rule planner.", + "assertions": [], + "answers": {} +} diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..6240dd8 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,65 @@ +# Highest-quality acceptance protocol + +“Highest quality” is an evidence claim, not a feature label. Repository tests +can validate architecture; they cannot substitute for expert, production or +adoption evidence. `qf audit --strict` therefore remains closed until every +critical operating gate has traceable evidence. + +## Release acceptance + +Every source release must pass from a clean environment: + +```bash +uv sync --all-extras --group dev +uv run pytest -q +uv run ruff check . +uv run ruff format --check . +qf ask --out .qf/report.html --evidence .qf/evidence.json +qf verify .qf/evidence.json +qf bench +qf audit +uv build +``` + +Install the resulting wheel into a fresh virtual environment and repeat the +offline `qf ask`/`qf verify` smoke test. A moved development virtual environment +is not release evidence. + +`qf verify` checks internal hashes and manifests. Production distribution must +add a trusted signature/transparency registry; a self-hash is not publisher +authentication. + +## Research acceptance + +For each supported family, maintain at least 50 held-out, expert-audited cases +covering normal work, boundary dates, revised data, survivorship, wrong units, +missing permissions, method traps and required refusals. Pre-register: + +- acceptable plan and answer rubric; +- exact values, row sets and date boundaries where possible; +- two independent reviewers and disagreement adjudication; +- critical/major/minor error severity; +- family and data-adapter slices. + +Report separately: + +- plan acceptance rate (target at least 90%); +- answer acceptance rate (target at least 95%); +- known silent critical errors (target zero); +- run failure/refusal rate—never combine this with silent errors; +- citation and PIT correctness; +- reviewer time and plan-edit rate. + +## Production and product acceptance + +- No-network, read-only, resource-limited isolation passes adversarial escape, + entitlement and prompt-injection evaluations. +- Cancellable and resumable service achieves at least 99.5% success over a + declared measurement window, with p95 latency and recovery tests. +- Accepted lessons improve held-out outcomes without regression. +- A named expert cohort reaches at least 60% weekly retention and 50% median + time saved without reducing correctness or review standards. + +Only after all critical gates pass may a release claim PAT-level operating +evidence. Until then Quantifact must describe itself as an alpha or validated +architecture prototype, however impressive an individual demo appears. diff --git a/docs/adr/0004-evidence-package-is-the-product.md b/docs/adr/0004-evidence-package-is-the-product.md new file mode 100644 index 0000000..e4f0958 --- /dev/null +++ b/docs/adr/0004-evidence-package-is-the-product.md @@ -0,0 +1,27 @@ +# ADR 0004: the evidence package is the product + +## Status + +Accepted. + +## Decision + +Every successful run produces a versioned `ResearchEvidencePackage`. Reports, +CLI summaries and future conversations are projections of it. The package +includes the compiled plan, generated code, source-vintage manifest, output +fingerprints, contracts, repairs, claim lineage and an integrity hash. + +The admission state is named `admitted_for_expert_review`; it explicitly sets +`investment_approved=false`. + +## Consequences + +- A run's internal hashes can be verified offline with `qf verify`; publisher + authenticity requires a trusted external signature or registry. +- Claims can be traversed to tasks, source series, code and value identity. +- Interfaces can evolve without changing the durable research object. +- Packages contain code and metadata and must be protected according to the + connected data licence and the institution's research-confidentiality rules. +- A self-contained integrity hash detects inconsistency but is not a digital + signature: an attacker able to replace the whole package can recompute it. + Integrity proves neither publisher identity nor that an inference is true. diff --git a/docs/architecture.md b/docs/architecture.md index 5ca6939..d3f9189 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,11 @@ -# Architecture: four bounded subsystems +# Architecture: containing error in investment research -The architecture is organised around error containment, not around how many -agents appear in a diagram. Each subsystem owns one irreversible hand-off and -emits a reviewable artefact. +Quantifact is designed around one investment-research problem: a model can +produce a plausible analysis long before the question, data, implementation or +inference is fit for use. The architecture therefore reduces the model's error +space at explicit boundaries instead of relying on a larger prompt or a second +model's approval. Each subsystem owns one irreversible hand-off and emits a +reviewable artefact. ![Four-subsystem Quantifact architecture](assets/architecture.svg) @@ -55,6 +58,43 @@ runtime mutation. This is intentionally off the success path. Ordinary runs do not continually rewrite their own controls. +## PAT lifecycle mapped to executable components + +The public PAT flow is useful when read as a chain of contracts rather than a +chain of agent names: + +| PAT stage | Quantifact component | Required output | Implementation status | +|---|---|---|---| +| Expert knowledge and permissions | `User.entitlements`, workflow and lesson repositories, adapter catalog | permitted context and candidate data | **Partial** — entitlement-aware synthetic corpus and workflows; no enterprise identity provider or broad expert corpus | +| Chat Agent clarifies the question | `Quantifact.clarify()`, `RulePlanner` / `LLMPlanner` | resolved definitions, horizon and knowledge date | **Partial** — executable clarification; no persistent, resumable chat service | +| Analysis Plan fixes the definition | `ResearchDesign`, `AnalysisPlan`, `PlanCompiler` | compiled research contract and typed task graph | **Implemented** for the supported operation vocabulary | +| Coding Agent compiles the plan | `generate_all()`, codegen backend, `static_analysis` | checked Python functions and actual DAG | **Implemented**; real-model breadth remains an evaluation gap | +| Harness executes and verifies | `ExecutionHarness`, contracts, repair and review | verified frames or a named blocking failure | **Implemented prototype**; no production process/container isolation or same-layer parallel execution | +| Report and derived data | `ResearchEvidencePackage`, report renderer and governed writeback | integrity-checked evidence with code, source vintages, claim lineage and limits | **Implemented** | +| Real user feedback | `qf teach`, `draft_lesson()` | a typed candidate lesson and failing benchmark | **Minimal implementation** — one registered effect; no automatic conversation mining | +| Benchmark plus context/harness improvement | `BenchmarkSuite`, `LessonRepo`, full regression | approved release artefacts for a later version | **Partial** — regression gate exists; automated arbitrary harness changes and PR review service do not | + +This is why the project has four subsystems rather than four Python modules. +The subsystem boundaries are stable; implementations behind them can become +broader without changing who owns each decision. + +## Six error-containment layers + +| Error space constrained | Deterministic mechanism | What it prevents | What it cannot prove | +|---|---|---|---| +| Research definition | `ResearchDesign`, clarifications, knowledge date, claims, rivals and falsifiers | answering an undeclared or unfalsifiable question | that the chosen question is economically important | +| Plan structure | typed schemas, units, row grain, operation vocabulary and `PlanCompiler` | unknown inputs, cycles, unit conflicts and late-bound data | that the planned methodology is the best one | +| Generated code | one function per task, AST policy and actual-DAG cross-check | hidden dependencies, IO/network/system calls and loader date override | absence of every semantic programming error | +| Data availability | entitlement-aware catalog and PIT-bound loaders | reading data the user may not see or that was not knowable at `as_of` | completeness and economic correctness of the source data | +| Materialised result | schema, invariant, PIT, claim-evidence and self-review gates | empty, malformed, implausible or unsupported outputs reaching a report | causal truth, forecast skill or decision value | +| Organisational change | failing benchmark, candidate fix, full regression and human approval | feedback silently mutating the live system | that an accepted lesson generalises beyond evaluated cases | + +The achieved guarantee is deliberately narrow: within supported workflows, an +uncompiled plan, policy-violating function, future-dated input, failed contract +or evidence-free declared claim cannot become a successful report. Production +quality still requires held-out expert evaluations, broader tools, execution +isolation, reliability evidence and observed research outcomes. + ## Shared trust boundary Permission-aware point-in-time adapters serve both research understanding and diff --git a/docs/compare.md b/docs/compare.md index f408541..6a19693 100644 --- a/docs/compare.md +++ b/docs/compare.md @@ -58,3 +58,12 @@ series it writes back are the kind of input a strategy consumes. Not a backtester, not a trading system, not a data product, not a general agent framework. See the README section of the same name. + +## The strategic boundary + +Quantifact should interoperate with these ecosystems rather than reproduce +them. OpenBB-style providers and Qlib-style data/model assets belong behind the +adapter/tool boundary. RD-Agent-style experiment proposals belong behind the +plan/compiler boundary. External real-task benchmarks belong in the evaluation +protocol. None receives authority to waive PIT, method, execution or admission +contracts. diff --git a/docs/guides/production.md b/docs/guides/production.md index a051158..9a7be16 100644 --- a/docs/guides/production.md +++ b/docs/guides/production.md @@ -11,6 +11,17 @@ reduction of blast radius, not a sandbox. For anything that matters: - treat the workspace directory as untrusted output; - put a CPU and memory limit on the process. +`qf ask --execution process --task-timeout 10` adds a disposable worker process, +wall timeout and best-effort CPU/address-space limits. It contains crashes and +runaway tasks, but it still shares the host kernel and network namespace. It +does **not** satisfy the production isolation gate by itself; place the worker +inside a no-network container or VM with read-only data mounts. + +The prototype spawns one process per uncached task, which prioritises a fresh +boundary over latency. A production service should use a pre-warmed isolated +worker pool with one disposable task context per materialisation; process-mode +latency is not a service SLO. + ## Cost and latency With a model backend, one run of a 16-task plan costs roughly 20–50 calls diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index a98787f..86764d4 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -4,6 +4,7 @@ uv add git+https://github.com/leoncuhk/quantifact # PyPI release pending qf ask # the demo question, synthetic data, no key qf ask # again — every task from the value cache +qf verify .qf/report.evidence.json ``` ## What just happened @@ -36,6 +37,8 @@ art.result.trace("market_prices") # cache key, rows, timing art.verdicts # every contract verdict, layer by layer art.findings # what self review noticed art.written_series # outputs written back into the store +art.evidence # portable plan/code/source/claim/verdict package +art.evidence.verify() # internal manifest and integrity checks ``` ## Choosing a knowledge date diff --git a/docs/guides/write-an-adapter.md b/docs/guides/write-an-adapter.md index 980ed60..72d0235 100644 --- a/docs/guides/write-an-adapter.md +++ b/docs/guides/write-an-adapter.md @@ -40,9 +40,22 @@ uniqueness on the natural key. ## Conformance -Copy `tests/test_duckdb_adapter.py`, point the fixture at your adapter, and keep -the four properties it asserts: the catalog round-trips, reads are +Run the public suite first: + +```bash +qf adapter-check --early-as-of 2022-03-01 --late-as-of 2026-08-01 \ + --json adapter-conformance.json +``` + +Programmatically, call `quantifact.check_adapter(your_adapter, ...)`. The suite +samples catalog semantics, future-date exclusion, monotone vintage visibility, +deterministic fingerprints and point-in-time reference tables. + +Then copy `tests/test_duckdb_adapter.py`, point the fixture at your adapter, and +keep its integration properties: the catalog round-trips, reads are point-in-time, universes are survivorship-free, and the same plan runs unchanged. +Passing conformance is necessary but not sufficient: it does not prove source +accuracy, complete revision history, licence compliance or full-catalog quality. ## Entitlements diff --git a/docs/index.md b/docs/index.md index a331267..edd414f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,9 @@ - [Evals and benchmarks](guides/evals.md) - [Running it for real](guides/production.md) - [Quality model and delivery gates](quality-model.md) +- [Product, buyer and operating model](product-operating-model.md) +- [Highest-quality acceptance protocol](acceptance.md) +- [Maturity matrix and roadmap](../ROADMAP.md) - [Release process and evidence gate](releasing.md) **See it** @@ -29,11 +32,12 @@ | import | what it is | |---|---| | `quantifact.Quantifact` | the system: `clarify`, `build_plan`, `analyse` | +| `ResearchEvidencePackage` | the durable, integrity-checked run product | | `AnalysisPlan`, `Task`, `ColumnSpec`, `ResearchDesign` | the intermediate representation and inference contract | | `PlanCompiler` | compile-time validation; returns execution layers | -| `Adapter`, `DemoSyntheticAdapter` | the six-method data protocol, and one implementation | +| `Adapter`, `DemoSyntheticAdapter`, `check_adapter` | data protocol, demo and PIT conformance suite | | `SeriesMeta`, `SeriesStore`, `SeriesSearch` | catalog, bitemporal storage, binding | -| `ExecutionHarness`, `ValueCache` | execution and caching | +| `ExecutionHarness`, `ProcessExecutionHarness`, `ValueCache` | fast execution, process containment and caching | | `Verdict`, `TaskUnfixable` | contract results | | `ReferenceCodegen`, `generate_all` | the deterministic backend | | `Lesson`, `Benchmark`, `teach` | the flywheel | diff --git a/docs/prior-art.md b/docs/prior-art.md index fcf252b..fdffdbf 100644 --- a/docs/prior-art.md +++ b/docs/prior-art.md @@ -44,3 +44,32 @@ must work across independently supplied data systems: usable. - **Bitemporal modelling**: valid time versus knowledge time, from temporal databases, which is exactly the distinction finance calls point-in-time. + +## Open-source systems reviewed + +The relevant projects solve different layers; popularity is not evidence that +their trust model should be copied. + +| Project | Strength to learn from | What Quantifact should not infer | +|---|---|---| +| [Qlib](https://github.com/microsoft/qlib) | broad quant workflow, datasets, models and experiment infrastructure | a backtest/model platform by itself makes generated research admissible | +| [RD-Agent](https://github.com/microsoft/RD-Agent) | experiment generation, feedback and iterative R&D | autonomous iteration may rewrite shared controls without benchmark governance | +| [OpenBB](https://github.com/OpenBB-finance/OpenBB) | provider ecosystem and analyst/agent data interfaces | a broad catalog supplies revision-aware PIT semantics automatically | +| [FinRobot](https://github.com/AI4Finance-Foundation/FinRobot) | composable financial agents and approachable examples | more agent roles imply stronger correctness or reproducibility | +| [FinGPT](https://github.com/AI4Finance-Foundation/FinGPT) | financial models, datasets and evaluation community | model domain knowledge can replace data and method contracts | +| [ai-hedge-fund](https://github.com/virattt/ai-hedge-fund) | understandable multi-perspective demonstration | simulated personas constitute an institutional research process | +| [IRAB](https://github.com/Rabyte-Technology/Investment-Research-Agent-Benchmark) | real buy-side task taxonomy, per-task rubrics, gold references and held-out evaluation | an LLM judge alone is sufficient for exact numeric/PIT correctness | + +The synthesis is deliberate: borrow data/tool extensibility from OpenBB and +Qlib, experiment regression from RD-Agent, usable examples from financial-agent +projects, and real-task evaluation structure from IRAB. Keep PAT's thick plan, +system-owned execution and benchmark-gated learning as the control plane. + +This review directly produced three executable changes: + +- unsupported rule-planner questions now fail closed instead of receiving an + oil-event workflow; +- benchmark cases declare research family, risk tags, expected outcome and + severity, with sliced reports and a separate silent-critical-error count; +- third-party adapters have a public PIT conformance suite, and generated tasks + may run behind a disposable process boundary with explicit limitations. diff --git a/docs/product-operating-model.md b/docs/product-operating-model.md new file mode 100644 index 0000000..ccb537a --- /dev/null +++ b/docs/product-operating-model.md @@ -0,0 +1,86 @@ +# Product and operating model + +## The first-principles product + +Investment research is a controlled belief update, not a document-generation +task. A useful system must reduce the time from an ambiguous question to +decision-grade evidence while bounding undetected error, look-ahead, reviewer +effort and loss of organisational knowledge. + +Quantifact therefore optimises **trusted research capacity**: + +``` +research coverage × update speed × expert acceptance +× reproducibility × traceability +``` + +The durable product is a `ResearchEvidencePackage`. Chat and HTML are views of +that object. The package preserves the question, reviewed assumptions, +knowledge date, method contracts, source vintages and licences, plan, code, +output fingerprints, verdicts, repairs and claim-level lineage. Its admission +status means that the declared gates passed; it never means that an investment +was approved or a claim was proven true. + +## Who uses, benefits and pays + +| Role | Job to be done | Evidence of value | +|---|---|---| +| Analyst | remove repetitive binding, cleaning, reruns and chart updates | time to first reviewable evidence; plan edit rate | +| Portfolio manager | challenge a claim and update it when facts change | update latency; claim-to-source drill-down | +| Head of research / CIO | scale expert judgement and retain methods | workflow reuse; accepted evidence per expert hour | +| Risk, compliance and model governance | reconstruct what was knowable and why release was allowed | PIT violations; untraceable claims; review time | +| CTO / data platform | replace fragmented notebooks with governed execution | tool coverage; reliability; duplicated pipelines | + +The economic buyer is normally the CIO or Head of Research where speed and +coverage dominate, the COO/CRO where governance dominates, and the CTO/CDO for +platform consolidation. The initial customer should have expensive research +labour, repeated workflows, versioned data, high review cost and material error +consequences. A team seeking only news summaries is not the target. + +Annual value should be measured, not asserted: + +``` +hours saved + value of shorter decision latency + expected loss avoided ++ coverage/capacity gained - total operating cost +``` + +Alpha is an important eventual outcome but a poor early sales metric because it +is noisy and difficult to attribute. Early pilots should pre-register time, +review effort, coverage, serious-error escape rate and adoption metrics. + +## Authority model + +| Actor | May decide | May not decide | +|---|---|---| +| Model | propose a plan, implementation, repair, rival or test | permissions, mandatory gates, evidence admission | +| System | data visibility, execution, cache identity, checks and admission | economic importance, portfolio action | +| Expert | approve the design, challenge evidence, accept lessons and make decisions | silently waive provenance or mandatory controls | + +In short: **the model proposes, the system admits evidence, the expert judges**. + +## Build order + +1. Select one repeated, high-value research family with observable correctness. +2. Build expert-authored held-out questions, gold plans, traps and refusal cases. +3. Connect licensed point-in-time data and encode its semantics in adapters. +4. Compile that family's methods into deterministic contracts. +5. Run generated code in isolated, resource-limited workers. +6. Integrate evidence review into the tools researchers already use. +7. Expand only from reproduced real failures, with full regression and approval. + +Do not begin with a universal autonomous analyst. Breadth without evaluated +methods increases the surface on which plausible wrong answers can escape. + +## Organisational ownership + +- **Research methods council:** taxonomy, gold plans, method contracts, error + severity and acceptance. Domain experts have veto power over methodology. +- **Data and platform:** PIT/vintage semantics, permissions, licences, + isolation, lineage, reliability and cost. +- **Agent and evaluation:** planner/compiler backends, repair, evaluation + harness, failure clustering, model upgrades and adversarial tests. +- **Single product owner:** accountable for expert-accepted evidence, escaped + critical errors, review time, reuse and adoption—not model calls or reports. + +Shared controls change only through a reproduced failure, a failing benchmark, +a candidate change, full regression and named human approval. diff --git a/docs/quality-model.md b/docs/quality-model.md index 13d633d..a9b8ea4 100644 --- a/docs/quality-model.md +++ b/docs/quality-model.md @@ -82,6 +82,10 @@ schema, but do not turn estimates into scores. Exit: no automatic critical blocker and reproducible install/run from a wheel. +**Current:** complete at repository level. The package, evidence object, clean +wheel smoke test and automatic probes reproduce. This does not advance the +operating gates below. + ### Gate 2 — prove research breadth - Build a taxonomy of 6–10 research families with domain experts. @@ -92,6 +96,10 @@ Exit: no automatic critical blocker and reproducible install/run from a wheel. Exit: at least 90% acceptable plans, no invented identifiers, and every failure is explicit. +**Current:** protocol implemented, evidence incomplete. Cases carry family, +risk, expected outcome and severity; unsupported rule workflows refuse. The +public set is four cases, not the required expert-held-out set of at least 50. + ### Gate 3 — prove decision-grade answers - Attach a licensed point-in-time dataset and document corpus. diff --git a/docs/releasing.md b/docs/releasing.md index d9661c7..4a1d6c2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,22 @@ # Releasing -A tag is a claim about the installable artifact, not the source tree. The -release workflow publishes only when all of these are true: +A tag is a claim about the installable artifact, not the source tree. Every +release, including an alpha, publishes only when all of these are true: 1. lint, format, tests, examples and deterministic benchmarks pass; 2. wheel and sdist build, and the wheel passes an isolated install smoke test; 3. the secret scan passes over history and the working tree; -4. `benchmarks/quality-evidence.json` supplies traceable operating evidence; -5. `qf audit --evidence benchmarks/quality-evidence.json --strict` reports - `PAT-level evidence`; -6. version, changelog and citation metadata agree; -7. the tag is signed or created through the protected GitHub release flow. +4. all public research-family and refusal evaluations pass with zero silent + critical failures; +5. version, changelog, citation metadata and tag agree; +6. GitHub publishes the wheel and sdist with build provenance. + +Stable releases add two gates that alpha releases deliberately cannot claim: + +7. `benchmarks/quality-evidence.json` supplies traceable operating evidence; +8. `qf audit --evidence benchmarks/quality-evidence.json --strict` reports + `PAT-level evidence`, after which the trusted-publishing job may publish to + PyPI. Do not copy the example evidence file and fill it with estimates. Each operating criterion requires an immutable artifact, measurement date, sample size and @@ -23,11 +29,14 @@ Local release-candidate check: uv run ruff format --check src tests examples tools uv run ruff check . uv run pytest -q +uv run qf evals --dir benchmarks +uv run qf adapter-check uv run qf bench -uv run qf audit --evidence benchmarks/quality-evidence.json --strict +uv run qf audit +uv run python tools/check_release.py uv build ``` -If strict audit fails, publish neither a PAT-level claim nor a final tag. A -source snapshot or explicitly labelled release candidate may still be shared -for evaluation. +If strict audit fails, publish neither a PAT-level claim nor a stable/PyPI +release. An explicitly labelled PEP 440 prerelease may still be published on +GitHub for evaluation when every repository-level gate passes. diff --git a/pyproject.toml b/pyproject.toml index 4cbd314..54a8c57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quantifact" -version = "0.2.0" +version = "0.3.0a1" description = "An investment-research agent that has to prove its numbers: plan as IR, contracts as gatekeepers, point-in-time by construction." readme = "README.md" requires-python = ">=3.12" @@ -32,8 +32,10 @@ duckdb = [ [project.urls] Homepage = "https://github.com/leoncuhk/quantifact" -Documentation = "https://github.com/leoncuhk/quantifact/tree/main/docs" +Documentation = "https://github.com/leoncuhk/quantifact/blob/main/docs/index.md" Issues = "https://github.com/leoncuhk/quantifact/issues" +Changelog = "https://github.com/leoncuhk/quantifact/blob/main/CHANGELOG.md" +Source = "https://github.com/leoncuhk/quantifact" [project.scripts] qf = "quantifact.cli:main" diff --git a/site/README.md b/site/README.md index 1f95690..41dfaf4 100644 --- a/site/README.md +++ b/site/README.md @@ -7,6 +7,11 @@ and reproducible benchmarks. The architecture SVG is inlined from the same source used by the repository README, so arrow semantics cannot drift. The page has no runtime dependencies and can be served directly by GitHub Pages. +The explorer shows implementation status explicitly. It is a synthetic, +reproducible architecture demonstration—not evidence of expert accuracy, +production isolation or investment performance. The repository maturity matrix +is the authoritative statement of remaining gates. + ```bash uv run python tools/export_site_data.py site/data.json # run the system, export artefacts uv run python tools/build_site.py # build site/index.html diff --git a/site/index.html b/site/index.html index 7b1eb2f..3b007b1 100644 --- a/site/index.html +++ b/site/index.html @@ -59,7 +59,10 @@ background:var(--paper);border:1px solid var(--line);border-radius:12px}.system:nth-child(4){border-color:var(--violet)} .system h3{font-size:18px;margin:10px 0}.system p{color:var(--copy);font-size:13px;margin:0 0 12px} .system ul{padding-left:18px;margin:0;color:var(--copy);font-size:13px}.system-output{font:11px/1.5 var(--mono); -color:var(--blue);margin-top:14px}.pipeline{display:grid;grid-template-columns:repeat(8,1fr);gap:16px} +color:var(--blue);margin-top:14px}.status{display:inline-block;padding:3px 8px;border-radius:999px; +font:10px var(--mono);margin-top:12px}.status.done{color:var(--green);background:var(--green2)} +.status.partial{color:#9a6700;background:#fff5c2}@media(prefers-color-scheme:dark){.status.partial{color:#ffd66b;background:#392f12}} +.pipeline{display:grid;grid-template-columns:repeat(8,1fr);gap:16px} .pipe{position:relative;padding:17px 13px;background:var(--paper);border:1px solid var(--line);border-radius:10px; min-height:124px}.pipe:not(:last-child):after{content:"→";position:absolute;right:-14px;top:43px;color:var(--muted); font:19px var(--mono);z-index:2}.pipe b{display:block;font-size:13px;margin:8px 0 5px}.pipe span{color:var(--copy);font-size:11px;line-height:1.4;display:block} @@ -87,7 +90,7 @@

Compile investment questions into evidence that can be challenged.

-
System engineering view

Separate runtime, platform controls, and governance.

+
Investment research system architecture

Contain error before model output becomes investment evidence.

The system boundary makes authority explicit. Three online subsystems compile evidence; shared services own models, data, execution and artefacts; the fourth subsystem governs learning outside the live success path.

@@ -267,15 +270,15 @@

Compile investment questions into evidence that can be challenged.

-
01 · research understanding

Make the question admissible

Prevents precise answers to vague or hindsight-defined questions.

  • communicate and clarify
  • retrieve context, documents and data
  • fix definitions, claims, rivals and falsifiers
OUT → ResearchDesign + AnalysisPlan
-
02 · analysis compiler

Constrain implementation

Prevents generated code from silently changing the research design.

  • compile the typed plan
  • split tasks and generate functions in parallel
  • derive and cross-check the actual DAG
OUT → checked functions + DAG
-
03 · controlled execution

Materialise verified values

Prevents terminal autonomy, look-ahead, skipped checks and wasteful reruns.

  • static inspection and PIT loaders
  • cache, layered validation and repair
  • self-review, report and receipt
OUT → evidence package or failure
-
04 · organisation learning

Improve without silent drift

Prevents user feedback from becoming an untested production mutation.

  • capture and reproduce a missed failure
  • create a benchmark and candidate change
  • full regression and human approval
OUT → versioned lesson + benchmark
+
01 · research understanding

Make the question admissible

Prevents precise answers to vague or hindsight-defined questions.

  • communicate and clarify
  • retrieve context, documents and data
  • fix definitions, claims, rivals and falsifiers
OUT → ResearchDesign + AnalysisPlan
PARTIAL · executable core
+
02 · analysis compiler

Constrain implementation

Prevents generated code from silently changing the research design.

  • compile the typed plan
  • split tasks and generate functions in parallel
  • derive and cross-check the actual DAG
OUT → checked functions + DAG
IMPLEMENTED · supported ops
+
03 · controlled execution

Materialise verified values

Prevents terminal autonomy, look-ahead, skipped checks and wasteful reruns.

  • static inspection and PIT loaders
  • cache, layered validation and repair
  • self-review, report and receipt
OUT → evidence package or failure
IMPLEMENTED · prototype isolation
+
04 · organisation learning

Improve without silent drift

Prevents user feedback from becoming an untested production mutation.

  • capture and reproduce a missed failure
  • create a benchmark and candidate change
  • full regression and human approval
OUT → versioned lesson + benchmark
MINIMUM LOOP · one effect
-
Investment research workflow

From expert question to compounding research memory.

+
PAT lifecycle implemented in Quantifact

From expert question to governed research memory.

The success path produces evidence. Learning is a separate governed loop: only an audited failure or expert correction enters it.

diff --git a/site/template.html b/site/template.html index b5e617d..d4385b1 100644 --- a/site/template.html +++ b/site/template.html @@ -59,7 +59,10 @@ background:var(--paper);border:1px solid var(--line);border-radius:12px}.system:nth-child(4){border-color:var(--violet)} .system h3{font-size:18px;margin:10px 0}.system p{color:var(--copy);font-size:13px;margin:0 0 12px} .system ul{padding-left:18px;margin:0;color:var(--copy);font-size:13px}.system-output{font:11px/1.5 var(--mono); -color:var(--blue);margin-top:14px}.pipeline{display:grid;grid-template-columns:repeat(8,1fr);gap:16px} +color:var(--blue);margin-top:14px}.status{display:inline-block;padding:3px 8px;border-radius:999px; +font:10px var(--mono);margin-top:12px}.status.done{color:var(--green);background:var(--green2)} +.status.partial{color:#9a6700;background:#fff5c2}@media(prefers-color-scheme:dark){.status.partial{color:#ffd66b;background:#392f12}} +.pipeline{display:grid;grid-template-columns:repeat(8,1fr);gap:16px} .pipe{position:relative;padding:17px 13px;background:var(--paper);border:1px solid var(--line);border-radius:10px; min-height:124px}.pipe:not(:last-child):after{content:"→";position:absolute;right:-14px;top:43px;color:var(--muted); font:19px var(--mono);z-index:2}.pipe b{display:block;font-size:13px;margin:8px 0 5px}.pipe span{color:var(--copy);font-size:11px;line-height:1.4;display:block} @@ -87,21 +90,21 @@

Compile investment questions into evidence that can be challenged.

-
System engineering view

Separate runtime, platform controls, and governance.

+
Investment research system architecture

Contain error before model output becomes investment evidence.

The system boundary makes authority explicit. Three online subsystems compile evidence; shared services own models, data, execution and artefacts; the fourth subsystem governs learning outside the live success path.

__ARCHITECTURE__
-
01 · research understanding

Make the question admissible

Prevents precise answers to vague or hindsight-defined questions.

  • communicate and clarify
  • retrieve context, documents and data
  • fix definitions, claims, rivals and falsifiers
OUT → ResearchDesign + AnalysisPlan
-
02 · analysis compiler

Constrain implementation

Prevents generated code from silently changing the research design.

  • compile the typed plan
  • split tasks and generate functions in parallel
  • derive and cross-check the actual DAG
OUT → checked functions + DAG
-
03 · controlled execution

Materialise verified values

Prevents terminal autonomy, look-ahead, skipped checks and wasteful reruns.

  • static inspection and PIT loaders
  • cache, layered validation and repair
  • self-review, report and receipt
OUT → evidence package or failure
-
04 · organisation learning

Improve without silent drift

Prevents user feedback from becoming an untested production mutation.

  • capture and reproduce a missed failure
  • create a benchmark and candidate change
  • full regression and human approval
OUT → versioned lesson + benchmark
+
01 · research understanding

Make the question admissible

Prevents precise answers to vague or hindsight-defined questions.

  • communicate and clarify
  • retrieve context, documents and data
  • fix definitions, claims, rivals and falsifiers
OUT → ResearchDesign + AnalysisPlan
PARTIAL · executable core
+
02 · analysis compiler

Constrain implementation

Prevents generated code from silently changing the research design.

  • compile the typed plan
  • split tasks and generate functions in parallel
  • derive and cross-check the actual DAG
OUT → checked functions + DAG
IMPLEMENTED · supported ops
+
03 · controlled execution

Materialise verified values

Prevents terminal autonomy, look-ahead, skipped checks and wasteful reruns.

  • static inspection and PIT loaders
  • cache, layered validation and repair
  • self-review, report and receipt
OUT → evidence package or failure
IMPLEMENTED · prototype isolation
+
04 · organisation learning

Improve without silent drift

Prevents user feedback from becoming an untested production mutation.

  • capture and reproduce a missed failure
  • create a benchmark and candidate change
  • full regression and human approval
OUT → versioned lesson + benchmark
MINIMUM LOOP · one effect
-
Investment research workflow

From expert question to compounding research memory.

+
PAT lifecycle implemented in Quantifact

From expert question to governed research memory.

The success path produces evidence. Learning is a separate governed loop: only an audited failure or expert correction enters it.

diff --git a/src/quantifact/__init__.py b/src/quantifact/__init__.py index 7a49214..f8158ea 100644 --- a/src/quantifact/__init__.py +++ b/src/quantifact/__init__.py @@ -19,10 +19,12 @@ from .contracts.verdict import TaskUnfixable, Verdict from .data.adapters.base import Adapter from .data.adapters.demo_synthetic import DemoSyntheticAdapter +from .data.conformance import AdapterConformanceReport, check_adapter from .data.registry import SeriesMeta, SeriesStore from .data.search import SeriesSearch +from .evidence import ResearchEvidencePackage from .harness.cache import ValueCache -from .harness.execute import ExecutionHarness +from .harness.execute import ExecutionHarness, ProcessExecutionHarness from .learn.benchmarks import Benchmark, BenchmarkSuite from .learn.lessons import Lesson, LessonRepo from .learn.teach import teach @@ -36,13 +38,14 @@ ResearchDesign, Task, ) -from .planner import RulePlanner +from .planner import RulePlanner, UnsupportedQuestionError __all__ = [ "ANALYST", "AlternativeExplanation", "PM", "Adapter", + "AdapterConformanceReport", "AnalysisPlan", "Artifacts", "Benchmark", @@ -55,8 +58,10 @@ "LessonRepo", "PlanCompiler", "PlanError", + "ProcessExecutionHarness", "Quantifact", "ReferenceCodegen", + "ResearchEvidencePackage", "ResearchClaim", "ResearchDesign", "RulePlanner", @@ -66,9 +71,11 @@ "Task", "TaskUnfixable", "User", + "UnsupportedQuestionError", "ValueCache", "Verdict", "generate_all", + "check_adapter", "teach", ] -__version__ = "0.2.0" +__version__ = "0.3.0a1" diff --git a/src/quantifact/agent.py b/src/quantifact/agent.py index c502b27..fd11adf 100644 --- a/src/quantifact/agent.py +++ b/src/quantifact/agent.py @@ -28,12 +28,19 @@ from .codegen.base import CodegenBackend, generate_all, schemas_of from .codegen.reference import ReferenceCodegen from .contracts.layers import validate_result, validate_static +from .contracts.methods import validate_method_evidence from .contracts.reasoning import validate_claim_evidence from .contracts.verdict import TaskUnfixable, Verdict from .data.adapters.demo_synthetic import DemoSyntheticAdapter from .data.registry import SeriesMeta +from .evidence import ResearchEvidencePackage, build_evidence_package from .harness.cache import ValueCache -from .harness.execute import ExecutionHarness, RunResult, TaskExecutionError +from .harness.execute import ( + ExecutionHarness, + ProcessExecutionHarness, + RunResult, + TaskExecutionError, +) from .learn.lessons import LessonRepo from .learn.workflows import WorkflowRepo from .plan.compile import PlanCompiler @@ -69,6 +76,8 @@ class Artifacts: fix_rounds: int = 0 planning_trace: dict[str, Any] = field(default_factory=dict) repair_trace: list[dict[str, Any]] = field(default_factory=list) + evidence: ResearchEvidencePackage | None = None + evidence_path: Path | None = None @property def layers(self) -> list[list[str]]: @@ -156,13 +165,23 @@ def __init__( debugger: Any | None = None, semantic_validator: Any | None = None, planner_backend: Any | None = None, + execution_mode: str = "in_process", + task_timeout_seconds: float = 30.0, ): self.ws = Path(workspace) self.ws.mkdir(parents=True, exist_ok=True) self.user = user self.adapter = adapter or DemoSyntheticAdapter(self.ws / "store") self.cache = ValueCache(self.ws / "cache", enabled=cache_enabled) - self.harness = ExecutionHarness(self.adapter, self.cache) + if execution_mode == "in_process": + self.harness = ExecutionHarness(self.adapter, self.cache) + elif execution_mode == "process": + self.harness = ProcessExecutionHarness( + self.adapter, self.cache, timeout_seconds=task_timeout_seconds + ) + else: + raise ValueError("execution_mode must be 'in_process' or 'process'") + self.execution_mode = execution_mode self.backend = backend or ReferenceCodegen() self.lessons = LessonRepo(self.ws / "context" / "lessons") self.workflows = WorkflowRepo(self.ws / "context" / "workflows") @@ -218,6 +237,7 @@ def analyse( out: str | Path | None = None, max_fix_rounds: int = 2, writeback: bool = True, + evidence_out: str | Path | None = None, on_stage: Callable[[str, float], None] | None = None, ) -> Artifacts: timings: dict[str, float] = {} @@ -295,6 +315,14 @@ def execute() -> RunResult: if failed_reasoning: raise TaskUnfixable(failed_reasoning[0]) + method_verdicts = stage( + "method_contracts", lambda: validate_method_evidence(plan, result.frames) + ) + verdicts += method_verdicts + failed_methods = [v for v in method_verdicts if not v.ok] + if failed_methods: + raise TaskUnfixable(failed_methods[0]) + frame_verdicts = stage( "validate", lambda: [ @@ -389,7 +417,7 @@ def execute() -> RunResult: ), ) - return Artifacts( + artifacts = Artifacts( plan=plan, codes=codes, result=result, @@ -402,6 +430,33 @@ def execute() -> RunResult: planning_trace=planning_trace, repair_trace=repair_trace, ) + package = stage( + "evidence", + lambda: build_evidence_package( + plan=plan, + codes=codes, + result=result, + findings=findings, + verdicts=verdicts, + timings=timings, + planning_trace=planning_trace, + repair_trace=repair_trace, + adapter=self.adapter, + backend=self.backend.name, + user=self.user.name, + report_path=report_path, + execution_mode=self.execution_mode, + ), + ) + package_path = None + target = evidence_out + if target is None and out is not None: + target = Path(out).with_suffix(".evidence.json") + if target is not None: + package_path = package.save(target) + artifacts.evidence = package + artifacts.evidence_path = package_path + return artifacts # ------------------------------------------------------------ fix loops def _repair_static( diff --git a/src/quantifact/cli.py b/src/quantifact/cli.py index 6c9e189..d488811 100644 --- a/src/quantifact/cli.py +++ b/src/quantifact/cli.py @@ -10,6 +10,8 @@ qf evals run the benchmark suite qf bench run the performance benchmarks qf audit evidence-backed PAT maturity audit +qf verify verify a research evidence package offline +qf adapter-check run the point-in-time adapter conformance suite """ from __future__ import annotations @@ -80,6 +82,8 @@ def cmd_ask(args) -> int: debugger=debugger, semantic_validator=semantic, planner_backend=_planner_backend(args), + execution_mode=args.execution, + task_timeout_seconds=args.task_timeout, ) answers = json.loads(args.answers) if args.answers else {} if args.as_of: @@ -98,6 +102,7 @@ def stage(name: str, secs: float) -> None: out=out, on_stage=stage, max_fix_rounds=args.fix_rounds, + evidence_out=args.evidence, ) print(f"\nas_of {art.plan.as_of} (nothing published later was read)") print(f"plan {len(art.plan.tasks)} tasks in {len(art.layers)} layers") @@ -117,6 +122,8 @@ def stage(name: str, secs: float) -> None: if art.fix_rounds: print(f"repairs {art.fix_rounds} round(s)") print(f"report {art.report_path}") + if art.evidence_path: + print(f"evidence {art.evidence_path} ({art.evidence.package_id[:12]})") if args.receipt: receipt = art.receipt(backend=qf.backend.name, user=qf.user.name) if backend is not None: @@ -280,9 +287,10 @@ def cmd_evals(args) -> int: ws = Path(args.workspace) qf = Quantifact(ws, _user(args.user)) suite = BenchmarkSuite(Path(args.dir) if args.dir else ws / "benchmarks") - results = suite.run_all( + report = suite.report( qf.adapter, LessonRepo(ws / "context" / "lessons").all(), qf.user.entitlements ) + results = report.results if not results: print("no benchmarks yet — run `qf teach` first") return 0 @@ -293,6 +301,12 @@ def cmd_evals(args) -> int: print(f" {f}") failed += not r.passed print(f"\n{len(results) - failed}/{len(results)} passing") + for family, counts in report.slices("family").items(): + print(f" family {family:<24} {counts['passed']}/{counts['total']}") + print(f"silent critical failures: {report.silent_critical_failures}") + if args.json: + Path(args.json).write_text(json.dumps(report.to_dict(), indent=2)) + print(f"wrote {args.json}") return 1 if failed else 0 @@ -321,6 +335,43 @@ def cmd_audit(args) -> int: return 1 if args.strict and report.level != "PAT-level evidence" else 0 +def cmd_verify(args) -> int: + """Verify internal package integrity; this is not publisher authentication.""" + from .evidence import ResearchEvidencePackage + + package = ResearchEvidencePackage.load(args.package) + problems = package.verify() + if problems: + print("INVALID") + for problem in problems: + print(f" - {problem}") + return 1 + print(f"VALID {package.package_id}") + print(f"as_of {package.payload['as_of']}") + print(f"admission {package.payload['admission']['decision']}") + print("authenticity not established; verify provenance through a trusted registry") + print("investment not approved; expert judgement remains required") + return 0 + + +def cmd_adapter_check(args) -> int: + from .data.conformance import check_adapter + + qf = Quantifact(args.workspace, _user(args.user)) + report = check_adapter( + qf.adapter, + early_as_of=args.early_as_of, + late_as_of=args.late_as_of, + sample_size=args.sample_size, + ) + for check in report.checks: + print(f"{'PASS' if check.passed else 'FAIL'} {check.id:<34} {check.evidence}") + if args.json: + Path(args.json).write_text(json.dumps(report.to_dict(), indent=2)) + print(f"wrote {args.json}") + return 0 if report.passed else 1 + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( prog="qf", @@ -338,7 +389,18 @@ def main(argv: list[str] | None = None) -> int: a.add_argument("--answers", help="JSON dict of clarification answers") a.add_argument("--out", help="report path") a.add_argument("--receipt", help="write a JSON receipt of this run") + a.add_argument( + "--evidence", + help="write the versioned research evidence package (default: beside report)", + ) a.add_argument("--no-cache", action="store_true") + a.add_argument( + "--execution", + choices=["in_process", "process"], + default="in_process", + help="process contains crashes/timeouts; it is not a no-network sandbox", + ) + a.add_argument("--task-timeout", type=float, default=30.0) a.add_argument("--backend", default="reference", choices=["reference", "llm"]) a.add_argument("--fix", action="store_true", help="enable the debugger agent") a.add_argument("--semantic", action="store_true", help="enable L3 review") @@ -393,6 +455,7 @@ def main(argv: list[str] | None = None) -> int: e = sub.add_parser("evals") e.set_defaults(fn=cmd_evals) e.add_argument("--dir", help="benchmark directory (default: the workspace's)") + e.add_argument("--json", help="write the sliced machine-readable evaluation") b = sub.add_parser("bench") b.set_defaults(fn=cmd_bench) @@ -409,8 +472,35 @@ def main(argv: list[str] | None = None) -> int: help="exit non-zero until PAT-level evidence is reached", ) + verify = sub.add_parser("verify") + verify.set_defaults(fn=cmd_verify) + verify.add_argument("package", help="research evidence package JSON") + + adapter = sub.add_parser("adapter-check") + adapter.set_defaults(fn=cmd_adapter_check) + adapter.add_argument("--early-as-of", default="2022-03-01") + adapter.add_argument("--late-as-of", default="2026-08-01") + adapter.add_argument("--sample-size", type=int, default=8) + adapter.add_argument("--json") + args = ap.parse_args(argv) - return args.fn(args) + try: + return args.fn(args) + except Exception as exc: + # Domain refusals are expected product outcomes. Keep programming bugs + # noisy, but do not present an unsupported question or failed contract + # as an internal crash. + from .contracts.point_in_time import LookAheadError + from .contracts.verdict import TaskUnfixable + from .plan.model import PlanError + from .planner import UnsupportedQuestionError + + if isinstance( + exc, (UnsupportedQuestionError, LookAheadError, PlanError, TaskUnfixable) + ): + print(f"refused: {exc}", file=sys.stderr) + return 2 + raise if __name__ == "__main__": diff --git a/src/quantifact/contracts/methods.py b/src/quantifact/contracts/methods.py new file mode 100644 index 0000000..c2e683c --- /dev/null +++ b/src/quantifact/contracts/methods.py @@ -0,0 +1,141 @@ +"""Research-family contracts. + +Generic schemas can prove that a dataframe matches its declaration. They +cannot prove that an event study declared its window before seeing results or +that a historical analogy avoided causal overclaiming. Method contracts close +that gap one research family at a time and remain ordinary Python gates: a +model may propose a methodology, but it cannot waive its requirements. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pandas as pd + +from ..plan.model import AnalysisPlan +from .verdict import Verdict + +DesignCheck = Callable[[AnalysisPlan], list[str]] +EvidenceCheck = Callable[[AnalysisPlan, dict[str, pd.DataFrame]], list[str]] + + +def _event_study_design(plan: AnalysisPlan) -> list[str]: + problems: list[str] = [] + event_tasks = [ + t for t in plan.tasks if (t.op or {}).get("kind") == "event_window_return" + ] + if not event_tasks: + return ["event_study declares no event_window_return task"] + for task in event_tasks: + window = (task.op or {}).get("window_days") + if not isinstance(window, int) or window <= 0: + problems.append( + f"task '{task.name}' has no positive pre-declared event window" + ) + exact = [ + x + for x in task.invariants + if x.get("kind") == "row_count" and x.get("min") == x.get("max") + ] + if not exact: + problems.append(f"task '{task.name}' has no exact expected row-set contract") + unique = [x for x in task.invariants if x.get("kind") == "unique"] + if not unique: + problems.append( + f"task '{task.name}' does not declare event/entity uniqueness" + ) + design = plan.research_design + falsifiers = " ".join( + f for claim in (design.claims if design else []) for f in claim.falsifiers + ).lower() + if "window" not in falsifiers: + problems.append("event_study has no pre-declared window-sensitivity falsifier") + return problems + + +def _event_study_evidence( + plan: AnalysisPlan, frames: dict[str, pd.DataFrame] +) -> list[str]: + problems: list[str] = [] + for task in plan.tasks: + if (task.op or {}).get("kind") != "event_window_return": + continue + frame = frames.get(task.name) + if frame is None or frame.empty: + problems.append(f"event result '{task.name}' did not materialise") + continue + if {"episode", "market_id"} <= set(frame.columns): + counts = frame.groupby("episode")["market_id"].nunique() + if len(counts) < 2: + problems.append("event study materialised fewer than two episodes") + return problems + + +def _historical_analogy_design(plan: AnalysisPlan) -> list[str]: + design = plan.research_design + if design is None: + return ["historical_analogy has no research design"] + problems: list[str] = [] + if any(c.kind == "causal" for c in design.claims): + problems.append( + "historical analogy may not present an analogy as causal evidence" + ) + limits = " ".join(design.limitations).lower() + if not any(word in limits for word in ("observational", "causal", "forecast")): + problems.append("historical analogy does not disclose its inference limit") + if not design.alternatives: + problems.append("historical analogy declares no rival explanation") + return problems + + +def _historical_analogy_evidence( + plan: AnalysisPlan, frames: dict[str, pd.DataFrame] +) -> list[str]: + episode_frames = [df for df in frames.values() if "episode" in df.columns] + if not episode_frames: + return ["historical analogy materialised no episode-labelled evidence"] + episodes = {str(x) for df in episode_frames for x in df["episode"].dropna().unique()} + return ( + [] + if len(episodes) >= 2 + else ["historical analogy contains fewer than two episodes"] + ) + + +METHODS: dict[str, tuple[DesignCheck, EvidenceCheck]] = { + "event_study": (_event_study_design, _event_study_evidence), + "historical_analogy": (_historical_analogy_design, _historical_analogy_evidence), +} + + +def validate_method_design(plan: AnalysisPlan) -> list[str]: + design = plan.research_design + if design is None: + return [] + problems: list[str] = [] + for method in design.methodologies: + registered = METHODS.get(method) + if registered is None: + problems.append(f"unknown research methodology '{method}'") + else: + problems.extend(registered[0](plan)) + return problems + + +def validate_method_evidence( + plan: AnalysisPlan, frames: dict[str, pd.DataFrame] +) -> list[Verdict]: + design = plan.research_design + if design is None: + return [] + verdicts: list[Verdict] = [] + for method in design.methodologies: + registered = METHODS.get(method) + problems = ( + [f"unknown research methodology '{method}'"] + if registered is None + else registered[1](plan, frames) + ) + verdicts.append(Verdict(method, "M-method", not problems, problems)) + return verdicts diff --git a/src/quantifact/data/adapters/demo_synthetic.py b/src/quantifact/data/adapters/demo_synthetic.py index 7362da8..1bd0dad 100644 --- a/src/quantifact/data/adapters/demo_synthetic.py +++ b/src/quantifact/data/adapters/demo_synthetic.py @@ -388,11 +388,29 @@ class DemoSyntheticAdapter: def __init__(self, root: str | Path, seed: int = 42): root = Path(root) - self.store = ( - SeriesStore(root) - if (root / "catalog.json").exists() - else build_store(root, seed) - ) + root.mkdir(parents=True, exist_ok=True) + # CLI commands may start concurrently against the same workspace. A + # build lock prevents one command from reading a partially populated + # catalog while another is still writing the demo Parquet files. + lock_path = root / ".build.lock" + with lock_path.open("a+") as lock: + try: + import fcntl + + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + except ImportError: # pragma: no cover - Windows is best effort + pass + existing = SeriesStore(root) if (root / "catalog.json").exists() else None + complete = existing is not None and all( + existing._path(series_id).exists() for series_id in existing.ids + ) + self.store = existing if complete else build_store(root, seed) + try: + import fcntl + + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + except ImportError: # pragma: no cover + pass self._universe = universe()[ ["market_id", "asset_class", "listed_from", "delisted_on"] ] diff --git a/src/quantifact/data/conformance.py b/src/quantifact/data/conformance.py new file mode 100644 index 0000000..349d558 --- /dev/null +++ b/src/quantifact/data/conformance.py @@ -0,0 +1,154 @@ +"""Executable conformance contract for third-party point-in-time adapters.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import date +from typing import Any + +import pandas as pd + +from ..plan.model import parse_date + + +@dataclass +class ConformanceCheck: + id: str + passed: bool + evidence: str + + +@dataclass +class AdapterConformanceReport: + adapter: str + checks: list[ConformanceCheck] + + @property + def passed(self) -> bool: + return bool(self.checks) and all(check.passed for check in self.checks) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": 1, + "adapter": self.adapter, + "passed": self.passed, + "checks": [asdict(check) for check in self.checks], + } + + +def check_adapter( + adapter: Any, + *, + early_as_of: str | date, + late_as_of: str | date, + sample_size: int = 8, +) -> AdapterConformanceReport: + """Probe temporal claims every production adapter must make reproducibly. + + Passing establishes protocol behaviour on sampled catalog entries. It does + not establish source correctness, licence compliance or full-catalog quality. + """ + early = parse_date(early_as_of) + late = parse_date(late_as_of) + if early >= late: + raise ValueError("early_as_of must be before late_as_of") + checks: list[ConformanceCheck] = [] + + catalog = adapter.catalog() + ids = [meta.series_id for meta in catalog] + checks.append(ConformanceCheck("catalog_nonempty", bool(ids), f"{len(ids)} series")) + unique = len(ids) == len(set(ids)) + checks.append( + ConformanceCheck( + "catalog_ids_unique", + unique, + "all identifiers unique" if unique else "duplicate series identifiers", + ) + ) + required = ("frequency", "unit", "source", "license_tag") + complete = all( + all(getattr(meta, field, None) for field in required) for meta in catalog + ) + checks.append( + ConformanceCheck( + "catalog_semantics_complete", + complete, + f"required fields: {', '.join(required)}", + ) + ) + + sampled = ids[:sample_size] + no_future = True + monotone = True + deterministic = True + read_errors: list[str] = [] + for sid in sampled: + try: + early_values = adapter.read_series(sid, as_of=early) + late_values = adapter.read_series(sid, as_of=late) + except Exception as exc: + read_errors.append(f"{sid}: {type(exc).__name__}: {exc}") + no_future = monotone = deterministic = False + continue + for values, cut in ((early_values, early), (late_values, late)): + if not isinstance(values, pd.Series): + no_future = False + continue + if len(values) and pd.Timestamp(values.index.max()) > pd.Timestamp(cut): + no_future = False + if not set(early_values.index).issubset(set(late_values.index)): + monotone = False + a = adapter.fingerprint([sid], as_of=early) + b = adapter.fingerprint([sid], as_of=early) + if not a or a != b: + deterministic = False + checks += [ + ConformanceCheck( + "sample_series_readable", + not read_errors, + ( + f"sampled {len(sampled)} series" + if not read_errors + else "; ".join(read_errors[:3]) + ), + ), + ConformanceCheck( + "no_future_observation_dates", + no_future, + f"sampled {len(sampled)} series at two vintages", + ), + ConformanceCheck( + "vintage_visibility_monotone", + monotone, + "early observation keys are a subset of later keys", + ), + ConformanceCheck( + "fingerprint_deterministic", + deterministic, + f"repeated fingerprints agree for {len(sampled)} series", + ), + ] + + table_names = adapter.tables() + tables_ok = True + for name in table_names: + frame = adapter.read_table(name, as_of=early) + tables_ok = tables_ok and isinstance(frame, pd.DataFrame) + date_columns = [ + column + for column in frame.columns + if pd.api.types.is_datetime64_any_dtype(frame[column]) + ] + for column in date_columns: + if len(frame) and pd.Timestamp(frame[column].max()) > pd.Timestamp(early): + tables_ok = False + checks.append( + ConformanceCheck( + "reference_tables_point_in_time", + tables_ok, + f"checked {len(table_names)} reference tables", + ) + ) + return AdapterConformanceReport( + getattr(adapter, "name", type(adapter).__name__), checks + ) diff --git a/src/quantifact/data/registry.py b/src/quantifact/data/registry.py index a2b8eaa..a423052 100644 --- a/src/quantifact/data/registry.py +++ b/src/quantifact/data/registry.py @@ -125,6 +125,7 @@ def __init__(self, root: str | Path): self.data_dir.mkdir(parents=True, exist_ok=True) self.index_path = self.root / "catalog.json" self._meta: dict[str, SeriesMeta] = {} + self._visible_fingerprints: dict[tuple[str, str, str], bytes] = {} if self.index_path.exists(): raw = json.loads(self.index_path.read_text()) self._meta = {k: SeriesMeta.from_dict(v) for k, v in raw.items()} @@ -171,6 +172,13 @@ def write( meta.content_hash = _content_hash(df) df.to_parquet(self._path(meta.series_id)) self._meta[meta.series_id] = meta + # A write changes the physical series and may change any visible + # vintage at or after its publication dates. + self._visible_fingerprints = { + key: value + for key, value in self._visible_fingerprints.items() + if key[0] != meta.series_id + } self.flush() return meta @@ -206,8 +214,21 @@ def fingerprint(self, series_ids: Iterable[str], *, as_of: str | date) -> str: cache entry, because they are answering different questions. """ h = hashlib.sha256() - h.update(str(parse_date(as_of)).encode()) + cut = pd.Timestamp(parse_date(as_of)) + h.update(str(cut.date()).encode()) for sid in sorted(series_ids): h.update(sid.encode()) - h.update(self._meta[sid].content_hash.encode()) + # Hash what this run was actually allowed to observe, not the full + # physical file. A later revision or publication must not alter + # the identity of an earlier vintage. + cache_id = (sid, str(cut.date()), self._meta[sid].content_hash) + visible_hash = self._visible_fingerprints.get(cache_id) + if visible_hash is None: + visible = self.read_frame(sid) + visible = visible[visible["pub_date"] <= cut] + visible_hash = hashlib.sha256( + pd.util.hash_pandas_object(visible, index=True).to_numpy().tobytes() + ).digest() + self._visible_fingerprints[cache_id] = visible_hash + h.update(visible_hash) return h.hexdigest()[:16] diff --git a/src/quantifact/evidence.py b/src/quantifact/evidence.py new file mode 100644 index 0000000..2896d88 --- /dev/null +++ b/src/quantifact/evidence.py @@ -0,0 +1,197 @@ +"""The durable product of a Quantifact run. + +Reports are views and conversations are transient. The evidence package is the +versioned, machine-verifiable research object: question, design, knowledge date, +source vintages and licences, code identity, materialised outputs, verdicts, +claim lineage and an explicit admission decision. Admission means only that +the declared evidence crossed the configured gates; it is never investment +approval or a claim that the inference is true. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from .harness.cache import RUNTIME_ID, frame_fingerprint + +SCHEMA_VERSION = "quantifact.evidence/1" + + +def _canonical(value: Any) -> str: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def _digest(value: Any) -> str: + return hashlib.sha256(_canonical(value).encode()).hexdigest() + + +@dataclass +class ResearchEvidencePackage: + payload: dict[str, Any] + + @property + def package_id(self) -> str: + return self.payload["integrity"]["sha256"] + + @property + def admitted(self) -> bool: + return self.payload["admission"]["evidence_admitted"] + + def verify(self) -> list[str]: + problems: list[str] = [] + if self.payload.get("schema_version") != SCHEMA_VERSION: + problems.append( + f"unsupported schema_version {self.payload.get('schema_version')!r}" + ) + integrity = self.payload.get("integrity", {}) + body = {k: v for k, v in self.payload.items() if k != "integrity"} + expected = _digest(body) + if integrity.get("sha256") != expected: + problems.append("package integrity hash does not match its contents") + if not self.payload.get("as_of"): + problems.append("package has no knowledge date") + if not self.payload.get("claims"): + problems.append("package carries no claim lineage") + plan = self.payload.get("plan") or {} + plan_names = {t.get("name") for t in plan.get("tasks", [])} + task_names = set(self.payload.get("tasks", {})) + if plan_names != task_names: + problems.append("package task manifest does not match its plan") + codes = self.payload.get("code", {}) + if set(codes) != task_names: + problems.append("package code manifest does not match its tasks") + for name, source in codes.items(): + actual = hashlib.sha256(source.encode()).hexdigest() + expected_code = self.payload["tasks"].get(name, {}).get("code_sha256") + if actual != expected_code: + problems.append(f"task '{name}' code hash does not match embedded source") + return problems + + def to_dict(self) -> dict[str, Any]: + return self.payload + + def save(self, path: str | Path) -> Path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(self.payload, indent=2, ensure_ascii=False)) + return target + + @staticmethod + def load(path: str | Path) -> ResearchEvidencePackage: + return ResearchEvidencePackage(json.loads(Path(path).read_text())) + + +def _task_sources(plan, name: str, memo: dict[str, list[str]]) -> list[str]: + if name in memo: + return memo[name] + task = plan[name] + sources = set(task.series_inputs) + for dep in task.depends_on: + sources.update(_task_sources(plan, dep, memo)) + memo[name] = sorted(sources) + return memo[name] + + +def build_evidence_package( + *, + plan, + codes: dict[str, str], + result, + findings, + verdicts, + timings: dict[str, float], + planning_trace: dict[str, Any], + repair_trace: list[dict[str, Any]], + adapter, + backend: str, + user: str, + report_path: Path | None, + execution_mode: str = "in_process", +) -> ResearchEvidencePackage: + metadata = {m.series_id: m for m in adapter.catalog()} + source_ids = plan.series_inputs() + sources = [] + for sid in source_ids: + meta = metadata[sid] + sources.append( + { + "series_id": sid, + "source": meta.source, + "license": meta.license_tag, + "frequency": meta.frequency, + "unit": meta.unit, + "first_observation": meta.first_obs, + "last_observation": meta.last_obs, + "last_publication": meta.last_pub, + "visible_fingerprint": adapter.fingerprint([sid], as_of=plan.as_of), + } + ) + + memo: dict[str, list[str]] = {} + tasks: dict[str, Any] = {} + for task in plan.tasks: + frame = result.frames[task.name] + tasks[task.name] = { + "type": task.type, + "depends_on": task.depends_on, + "source_series": _task_sources(plan, task.name, memo), + "code_sha256": hashlib.sha256(codes[task.name].encode()).hexdigest(), + "value_fingerprint": frame_fingerprint(frame), + "rows": len(frame), + "columns": list(frame.columns), + "cache_key": result.trace(task.name).cache_key, + } + + design = plan.research_design + claims = [] + if design: + for claim in design.claims: + claims.append( + { + **asdict(claim), + "evidence": {name: tasks[name] for name in claim.evidence_tasks}, + } + ) + + admission = { + "evidence_admitted": True, + "decision": "admitted_for_expert_review", + "meaning": ( + "All mandatory system gates completed. This is not investment approval, " + "proof that a claim is true, or authorisation to trade." + ), + "investment_approved": False, + "blocking_findings": [asdict(f) for f in findings if f.severity == "blocking"], + } + body = { + "schema_version": SCHEMA_VERSION, + "question": plan.question, + "as_of": plan.as_of, + "identity": { + "user": user, + "backend": backend, + "runtime": RUNTIME_ID, + "execution_mode": execution_mode, + }, + "admission": admission, + "research_design": asdict(design) if design else None, + "plan": plan.to_dict(), + "code": dict(codes), + "resolved_assumptions": plan.resolved_assumptions, + "sources": sources, + "tasks": tasks, + "claims": claims, + "verdicts": [asdict(v) for v in verdicts], + "findings": [asdict(f) for f in findings], + "planning_trace": planning_trace, + "repair_trace": repair_trace, + "timings": dict(timings), + "report": str(report_path) if report_path else None, + } + return ResearchEvidencePackage( + {**body, "integrity": {"algorithm": "sha256", "sha256": _digest(body)}} + ) diff --git a/src/quantifact/harness/execute.py b/src/quantifact/harness/execute.py index 14f6763..8ed9dea 100644 --- a/src/quantifact/harness/execute.py +++ b/src/quantifact/harness/execute.py @@ -14,6 +14,7 @@ from __future__ import annotations import builtins +import multiprocessing as mp import time from collections.abc import Callable from dataclasses import dataclass @@ -65,6 +66,68 @@ } +def _namespace(adapter: Any, as_of: str) -> dict[str, Any]: + """The generated-code namespace, shared by in-process and worker execution.""" + + def load_series(series_id: str) -> pd.Series: + return adapter.read_series(series_id, as_of=as_of) + + def load_table(name: str) -> pd.DataFrame: + return adapter.read_table(name, as_of=as_of) + + return { + "__builtins__": SAFE_BUILTINS, + "pd": pd, + "np": np, + "load_series": load_series, + "load_table": load_table, + } + + +def _execute_source( + adapter: Any, task_name: str, source: str, args: list[pd.DataFrame], as_of: str +) -> pd.DataFrame: + ns = _namespace(adapter, as_of) + exec(compile(source, f"", "exec"), ns) + fn = ns.get(task_name) + if not callable(fn): + raise RuntimeError(f"generated code defines no function '{task_name}'") + result = fn(*args) + if not isinstance(result, pd.DataFrame): + raise TypeError( + f"{task_name} returned {type(result).__name__}, expected DataFrame" + ) + return result.reset_index(drop=True) + + +def _process_worker( + connection, + adapter: Any, + task_name: str, + source: str, + args: list[pd.DataFrame], + as_of: str, + cpu_seconds: int, + memory_mb: int, +) -> None: + """Child entry point. Resource limits are best-effort and platform-specific.""" + try: + try: + import resource + + resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds + 1)) + if hasattr(resource, "RLIMIT_AS"): + limit = memory_mb * 1024 * 1024 + resource.setrlimit(resource.RLIMIT_AS, (limit, limit)) + except (ImportError, OSError, ValueError): + pass + connection.send((True, _execute_source(adapter, task_name, source, args, as_of))) + except BaseException as exc: # child must return a bounded error, then die + connection.send((False, f"{type(exc).__name__}: {exc}")) + finally: + connection.close() + + @dataclass class TaskTrace: task: str @@ -112,21 +175,7 @@ def __init__(self, adapter: Any, cache: ValueCache): def _namespace(self, as_of: str) -> dict[str, Any]: """Loaders bound to the knowledge date. Generated code cannot rebind them: static analysis rejects any keyword argument to a loader.""" - adapter = self.adapter - - def load_series(series_id: str) -> pd.Series: - return adapter.read_series(series_id, as_of=as_of) - - def load_table(name: str) -> pd.DataFrame: - return adapter.read_table(name, as_of=as_of) - - return { - "__builtins__": SAFE_BUILTINS, - "pd": pd, - "np": np, - "load_series": load_series, - "load_table": load_table, - } + return _namespace(self.adapter, as_of) def _compile( self, task_name: str, source: str, as_of: str @@ -153,13 +202,14 @@ def run_one( ) -> pd.DataFrame: """Execute a single task against already-materialised upstream frames. Used by the repair loop and by graders; no caching, no layering.""" - fn = self._compile(task.name, source, as_of) - df = fn(*[frames[d] for d in task.depends_on]) - if not isinstance(df, pd.DataFrame): - raise TypeError( - f"{task.name} returned {type(df).__name__}, expected DataFrame" - ) - return df.reset_index(drop=True) + return self._materialise( + task, source, [frames[d] for d in task.depends_on], as_of + ) + + def _materialise( + self, task: Task, source: str, args: list[pd.DataFrame], as_of: str + ) -> pd.DataFrame: + return _execute_source(self.adapter, task.name, source, args, as_of) # ----------------------------------------------------------------- run def run( @@ -203,9 +253,13 @@ def run( list(cached.columns), ) else: - fn = self._compile(name, codes[name], as_of) try: - df = fn(*[frames[d] for d in task.depends_on]) + df = self._materialise( + task, + codes[name], + [frames[d] for d in task.depends_on], + as_of, + ) except Exception as e: tr = TaskTrace( name, @@ -242,3 +296,65 @@ def run( on_task(tr) return RunResult(frames=frames, traces=traces, layers=layers, as_of=as_of) + + +class ProcessExecutionHarness(ExecutionHarness): + """Run each generated task in a disposable, resource-bounded process. + + This contains interpreter crashes, runaway CPU and wall-time overruns. It is + intentionally not called a sandbox: the worker still shares the host kernel + and network namespace. Production deployment must put this worker behind a + no-network container or VM boundary. + """ + + def __init__( + self, + adapter: Any, + cache: ValueCache, + *, + timeout_seconds: float = 10.0, + cpu_seconds: int = 5, + memory_mb: int = 1024, + ): + super().__init__(adapter, cache) + self.timeout_seconds = timeout_seconds + self.cpu_seconds = cpu_seconds + self.memory_mb = memory_mb + + def _materialise( + self, task: Task, source: str, args: list[pd.DataFrame], as_of: str + ) -> pd.DataFrame: + context = mp.get_context("spawn") + parent, child = context.Pipe(duplex=False) + process = context.Process( + target=_process_worker, + args=( + child, + self.adapter, + task.name, + source, + args, + as_of, + self.cpu_seconds, + self.memory_mb, + ), + ) + process.start() + child.close() + process.join(self.timeout_seconds) + if process.is_alive(): + process.terminate() + process.join(1) + parent.close() + raise TimeoutError( + f"task '{task.name}' exceeded {self.timeout_seconds:.2f}s wall limit" + ) + if not parent.poll(): + code = process.exitcode + parent.close() + raise RuntimeError(f"task worker exited without a result (exitcode={code})") + ok, value = parent.recv() + parent.close() + if not ok: + raise RuntimeError(value) + return value diff --git a/src/quantifact/learn/benchmarks.py b/src/quantifact/learn/benchmarks.py index 1b4e5ae..0465955 100644 --- a/src/quantifact/learn/benchmarks.py +++ b/src/quantifact/learn/benchmarks.py @@ -25,6 +25,10 @@ class Benchmark: origin: str = "teach" human_audited: bool = False note: str = "" + family: str = "unspecified" + risk_tags: list[str] = field(default_factory=list) + expected_outcome: str = "plan" # plan | refuse + severity: str = "major" # critical | major | minor def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -41,16 +45,82 @@ class BenchmarkResult: failures: list[str] = field(default_factory=list) +@dataclass +class BenchmarkReport: + results: list[BenchmarkResult] + + @property + def passed(self) -> int: + return sum(r.passed for r in self.results) + + @property + def total(self) -> int: + return len(self.results) + + @property + def silent_critical_failures(self) -> int: + return sum( + not r.passed + and r.benchmark.severity == "critical" + and r.benchmark.expected_outcome != "refuse" + for r in self.results + ) + + def slices(self, field: str) -> dict[str, dict[str, int]]: + grouped: dict[str, list[BenchmarkResult]] = {} + for result in self.results: + values = getattr(result.benchmark, field) + values = values if isinstance(values, list) else [values] + for value in values or ["unspecified"]: + grouped.setdefault(value, []).append(result) + return { + key: {"passed": sum(r.passed for r in rows), "total": len(rows)} + for key, rows in sorted(grouped.items()) + } + + def to_dict(self) -> dict[str, Any]: + return { + "passed": self.passed, + "total": self.total, + "silent_critical_failures": self.silent_critical_failures, + "by_family": self.slices("family"), + "by_risk": self.slices("risk_tags"), + "results": [ + { + "id": r.benchmark.id, + "family": r.benchmark.family, + "expected_outcome": r.benchmark.expected_outcome, + "severity": r.benchmark.severity, + "risk_tags": r.benchmark.risk_tags, + "passed": r.passed, + "failures": r.failures, + } + for r in self.results + ], + } + + class BenchmarkSuite: def __init__(self, root: str | Path): self.root = Path(root) self.root.mkdir(parents=True, exist_ok=True) def all(self) -> list[Benchmark]: - return [ - Benchmark.from_dict(json.loads(p.read_text())) - for p in sorted(self.root.glob("*.json")) - ] + benchmarks = [] + for path in sorted(self.root.rglob("*.json")): + payload = json.loads(path.read_text()) + # Benchmark directories may also contain performance runs, model + # trials, and operating-evidence templates. Only documents with + # the benchmark identity are cases; once identified, malformed + # fields still fail loudly in ``from_dict``. + if not isinstance(payload, dict) or not { + "id", + "prompt", + "assertions", + }.issubset(payload): + continue + benchmarks.append(Benchmark.from_dict(payload)) + return benchmarks def add(self, bench: Benchmark) -> Path: p = self.root / f"{bench.id}.json" @@ -76,10 +146,19 @@ def run( try: plan = planner.plan(bench.prompt, bench.answers) except Exception as e: + if bench.expected_outcome == "refuse": + return BenchmarkResult(bench, True) return BenchmarkResult( bench, False, [f"planning raised {type(e).__name__}: {e}"] ) + if bench.expected_outcome == "refuse": + return BenchmarkResult( + bench, + False, + ["planner produced a plan for a question that must be refused"], + ) + for a in bench.assertions: t = a["type"] if t == "plan_has_task": @@ -142,3 +221,8 @@ def run_all( self, adapter, lessons: list[Lesson], entitlements: tuple[str, ...] = () ) -> list[BenchmarkResult]: return [self.run(b, adapter, lessons, entitlements) for b in self.all()] + + def report( + self, adapter, lessons: list[Lesson], entitlements: tuple[str, ...] = () + ) -> BenchmarkReport: + return BenchmarkReport(self.run_all(adapter, lessons, entitlements)) diff --git a/src/quantifact/plan/compile.py b/src/quantifact/plan/compile.py index 736a690..ca965e1 100644 --- a/src/quantifact/plan/compile.py +++ b/src/quantifact/plan/compile.py @@ -19,6 +19,7 @@ from __future__ import annotations +from ..contracts.methods import validate_method_design from ..contracts.reasoning import validate_research_design from .layers import topo_layers from .model import ALLOWED_DTYPES, ALLOWED_ROLES, AnalysisPlan, PlanError, parse_date @@ -85,6 +86,7 @@ def validate(self, plan: AnalysisPlan) -> list[str]: if plan.research_design is not None or self.require_research_design: p.extend(validate_research_design(plan)) + p.extend(validate_method_design(plan)) if not plan.tasks: p.append("plan has no tasks") diff --git a/src/quantifact/plan/model.py b/src/quantifact/plan/model.py index b5b24a0..2eddc3b 100644 --- a/src/quantifact/plan/model.py +++ b/src/quantifact/plan/model.py @@ -89,6 +89,11 @@ class ResearchDesign: limitations: list[str] identification_strategy: str | None = None out_of_sample_test: str | None = None + # Research families activate deterministic, domain-specific design and + # evidence contracts. This is deliberately separate from question_type: + # a comparative question may be an event study, a cross-sectional study, + # or something else with materially different failure modes. + methodologies: list[str] = field(default_factory=list) @staticmethod def from_dict(d: dict[str, Any]) -> ResearchDesign: diff --git a/src/quantifact/planner.py b/src/quantifact/planner.py index 0fdfb5c..571c2c4 100644 --- a/src/quantifact/planner.py +++ b/src/quantifact/planner.py @@ -82,6 +82,26 @@ class BindingTrace: ) +class UnsupportedQuestionError(ValueError): + """The deterministic planner has no audited workflow for this question. + + Refusal is a correctness outcome. Reusing the one exact rule workflow for + an unrelated question would produce a beautifully contracted answer to the + wrong problem, which is more dangerous than an explicit limitation. + """ + + +_OIL_TERMS = { + "oil", + "supply shock", + "energy shock", + "middle east", + "hormuz", + "macro conditions", +} +_EVENT_TERMS = {"respond", "response", "compare", "episode", "event", "shock"} + + class RulePlanner: def __init__( self, @@ -94,8 +114,26 @@ def __init__( self.lessons = lessons or [] self.bindings: list[BindingTrace] = [] + def supports(self, prompt: str) -> bool: + """Whether the exact offline workflow is admissible for ``prompt``.""" + if not prompt.strip(): + return True # internal call used only to obtain defaults + text = prompt.lower() + return any(term in text for term in _OIL_TERMS) and any( + term in text for term in _EVENT_TERMS + ) + + def require_supported(self, prompt: str) -> None: + if not self.supports(prompt): + raise UnsupportedQuestionError( + "the rule planner supports oil/energy event studies and historical " + "analogies only; use --planner llm for compiler-bounded planning or " + "add an audited workflow and benchmark for this research family" + ) + # ------------------------------------------------------------- clarify def clarify(self, prompt: str) -> list[Clarification]: + self.require_supported(prompt) episodes = [ row["episode"] for _, row in self.adapter.read_table( @@ -249,6 +287,7 @@ def bind_universe(self, as_of: str) -> dict[str, str]: # ---------------------------------------------------------------- plan def plan(self, prompt: str, answers: dict[str, Any] | None = None) -> AnalysisPlan: + self.require_supported(prompt) given = dict(answers or {}) a = {**self.defaults(), **given} as_of: str = str(a["as_of"]) @@ -817,6 +856,7 @@ def plan(self, prompt: str, answers: dict[str, Any] | None = None) -> AnalysisPl design = ResearchDesign( question_type="comparative", + methodologies=["event_study", "historical_analogy"], decision_context=( "Assess whether the latest oil-supply episode is a useful historical " "analogue; this analysis informs further investigation, not a trade." diff --git a/src/quantifact/planner_llm.py b/src/quantifact/planner_llm.py index 60ade2d..065cf32 100644 --- a/src/quantifact/planner_llm.py +++ b/src/quantifact/planner_llm.py @@ -45,6 +45,7 @@ "resolved_assumptions": [""], "research_design": { "question_type": "descriptive | comparative | causal | predictive", + "methodologies": ["event_study | historical_analogy"], "decision_context": "", "claims": [{ "id": "snake_case", "statement": "", @@ -94,6 +95,7 @@ Hard rules, each one checked by the compiler before any code is written: - pre-register bounded claims, falsifiers, rival explanations and evidence tasks +- declare a registered methodology only when its deterministic method contracts apply - comparative/causal/predictive work must test at least one rival explanation - never label a claim causal without a causal question and identification strategy - never label a claim predictive without a stated out-of-sample test diff --git a/src/quantifact/quality.py b/src/quantifact/quality.py index c68bea6..8a1392e 100644 --- a/src/quantifact/quality.py +++ b/src/quantifact/quality.py @@ -253,6 +253,8 @@ def _auto_probes(workspace: Path) -> dict[str, tuple[float, str, str]]: """Cheap executable evidence. A failed probe scores zero and stays legible.""" from .agent import Quantifact from .data.adapters.base import DocumentSource + from .data.conformance import check_adapter + from .learn.benchmarks import Benchmark, BenchmarkSuite from .learn.teach import KNOWN_EFFECTS from .learn.workflows import WorkflowRepo from .planner_llm import LLMPlanner @@ -282,8 +284,8 @@ def _auto_probes(workspace: Path) -> dict[str, tuple[float, str, str]]: and receipt["code_sha256"] ) out["diagnosability"] = ( - 1 if trace_ok else 0.5, - "versioned receipt covers plan/code identity, planning, execution and checks", + 1 if trace_ok and art.evidence and not art.evidence.verify() else 0.5, + "verifiable evidence package covers plan/code, sources, claims, execution and checks", "complete the machine-readable run receipt" if not trace_ok else "", ) # Two vintages must expose different slices, and future documents must stay hidden. @@ -300,10 +302,14 @@ def _auto_probes(workspace: Path) -> dict[str, tuple[float, str, str]]: "numeric and document vintages probed", "add/fix bitemporal enforcement" if not pit else "", ) + adapter_report = check_adapter( + qf.adapter, early_as_of="2022-03-01", late_as_of="2026-08-01" + ) out["structured_data"] = ( - 0.7, + 0.75 if adapter_report.passed else 0.4, f"{len(qf.adapter.catalog())} synthetic series; " - "inspection and entitlements implemented", + f"{sum(c.passed for c in adapter_report.checks)}/{len(adapter_report.checks)} " + "adapter conformance checks passed", "validate against a licensed production catalog", ) docs = getattr(qf.adapter, "documents", None) @@ -313,9 +319,22 @@ def _auto_probes(workspace: Path) -> dict[str, tuple[float, str, str]]: "connect licensed corpora, web retrieval, chunking and retrieval evals", ) workflows = WorkflowRepo().all() + refusal_suite = BenchmarkSuite(workspace / "audit-refusals") + refusal_suite.add( + Benchmark( + id="unsupported-family", + prompt="Build a DCF valuation for a semiconductor company", + assertions=[], + family="equity_fundamental", + risk_tags=["unsupported_family"], + expected_outcome="refuse", + severity="critical", + ) + ) + refusal_ok = refusal_suite.report(qf.adapter, []).passed == 1 out["dynamic_planning"] = ( - 0.5 if LLMPlanner else 0, - "compiler-feedback planner implemented; stub-tested", + 0.6 if LLMPlanner and refusal_ok else 0, + "compiler-feedback planner stub-tested; unsupported rule workflows refuse", "run real-model, multi-domain held-out planner evaluations", ) out["feedback_gate"] = ( diff --git a/src/quantifact/report/render.py b/src/quantifact/report/render.py index b852b96..b47ac10 100644 --- a/src/quantifact/report/render.py +++ b/src/quantifact/report/render.py @@ -435,6 +435,13 @@ def render_report( parts.append(_kpi(k, str(v))) parts.append("
") + parts.append( + '

Evidence admission

' + "

Admitted for expert review. All mandatory system gates " + "completed. This status is not investment approval, proof that a claim " + "is true, or authorisation to trade.

" + ) + if plan.resolved_assumptions: parts.append('

Resolved assumptions

    ') parts += [f"
  • {html.escape(a)}
  • " for a in plan.resolved_assumptions] @@ -448,6 +455,12 @@ def render_report( f"

    Question type: {html.escape(design.question_type)}
    " f"Decision context: {html.escape(design.decision_context)}

    " ) + if design.methodologies: + parts.append( + "

    Method contracts: " + + html.escape(", ".join(design.methodologies)) + + "

    " + ) parts.append("

    Claims this analysis is allowed to support

      ") for claim in design.claims: tasks = ", ".join(claim.evidence_tasks) diff --git a/tests/test_adapter_conformance.py b/tests/test_adapter_conformance.py new file mode 100644 index 0000000..d99d5dd --- /dev/null +++ b/tests/test_adapter_conformance.py @@ -0,0 +1,39 @@ +"""Adapters prove their temporal contract through one public conformance suite.""" + +from __future__ import annotations + +from quantifact import check_adapter + + +def test_demo_adapter_passes_the_public_conformance_suite(qf): + report = check_adapter(qf.adapter, early_as_of="2022-03-01", late_as_of="2026-08-01") + assert report.passed + assert len(report.checks) >= 8 + assert all(check.evidence for check in report.checks) + + +def test_conformance_report_is_machine_readable(qf): + report = check_adapter( + qf.adapter, early_as_of="2022-03-01", late_as_of="2026-08-01", sample_size=2 + ) + payload = report.to_dict() + assert payload["schema_version"] == 1 + assert payload["passed"] is True + assert payload["adapter"] == qf.adapter.name + + +def test_missing_series_file_is_a_failed_check_not_a_crash(qf, monkeypatch): + original = qf.adapter.read_series + + def broken(series_id, *, as_of): + if series_id == qf.adapter.catalog()[0].series_id: + raise FileNotFoundError("simulated missing parquet") + return original(series_id, as_of=as_of) + + monkeypatch.setattr(qf.adapter, "read_series", broken) + report = check_adapter( + qf.adapter, early_as_of="2022-03-01", late_as_of="2026-08-01", sample_size=1 + ) + check = next(c for c in report.checks if c.id == "sample_series_readable") + assert not report.passed and not check.passed + assert "simulated missing parquet" in check.evidence diff --git a/tests/test_architecture.py b/tests/test_architecture.py index b5b2b91..d590c38 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -22,6 +22,7 @@ Quantifact, ReferenceCodegen, SeriesSearch, + UnsupportedQuestionError, ) from quantifact.bench import edit_last_chart, values_hash from quantifact.codegen.base import generate_all @@ -119,6 +120,34 @@ def test_the_real_plan_compiles(plan, qf): assert all(plan[n].type == "data_ingestion" for n in layers[0]) +def test_rule_planner_refuses_an_unsupported_research_family(qf): + with pytest.raises(UnsupportedQuestionError, match="supports oil/energy"): + qf.build_plan("Build a DCF valuation for a semiconductor company") + + +def test_rule_planner_does_not_offer_oil_clarifications_for_an_unrelated_question(qf): + with pytest.raises(UnsupportedQuestionError): + qf.clarify("Optimize my portfolio") + + +def test_cli_presents_an_unsupported_question_as_a_refusal(tmp_path, capsys): + from quantifact.cli import main + + status = main( + [ + "--workspace", + str(tmp_path / "ws"), + "plan", + "Build a DCF valuation for a semiconductor company", + ] + ) + captured = capsys.readouterr() + assert status == 2 + assert captured.out == "" + assert captured.err.startswith("refused:") + assert "supports oil/energy" in captured.err + + # -------------------------------------------------------- static analysis diff --git a/tests/test_evaluation_protocol.py b/tests/test_evaluation_protocol.py new file mode 100644 index 0000000..430e417 --- /dev/null +++ b/tests/test_evaluation_protocol.py @@ -0,0 +1,49 @@ +"""Evaluation reports expose coverage and silent failure, not one vanity score.""" + +from __future__ import annotations + +from quantifact.learn.benchmarks import Benchmark, BenchmarkReport, BenchmarkResult + + +def test_repository_benchmarks_include_success_and_refusal_cases(qf): + from pathlib import Path + + from quantifact.learn.benchmarks import BenchmarkSuite + + root = Path(__file__).resolve().parents[1] / "benchmarks" + suites = [BenchmarkSuite(root / name) for name in ("plan", "contract", "refusal")] + results = [r for suite in suites for r in suite.run_all(qf.adapter, [])] + report = BenchmarkReport(results) + assert report.total == 4 and report.passed == 4 + assert report.silent_critical_failures == 0 + assert report.slices("family")["equity_fundamental"] == {"passed": 1, "total": 1} + assert report.slices("risk_tags")["unsupported_family"] == { + "passed": 2, + "total": 2, + } + + +def test_repository_root_discovers_cases_without_loading_result_json(qf): + from pathlib import Path + + from quantifact.learn.benchmarks import BenchmarkSuite + + root = Path(__file__).resolve().parents[1] / "benchmarks" + report = BenchmarkSuite(root).report(qf.adapter, []) + + assert report.total == 4 + assert report.passed == 4 + + +def test_report_counts_a_silent_critical_failure(): + bench = Benchmark( + "bad", + "q", + [], + family="event_study", + severity="critical", + expected_outcome="plan", + ) + report = BenchmarkReport([BenchmarkResult(bench, False, ["wrong answer escaped"])]) + assert report.silent_critical_failures == 1 + assert report.to_dict()["by_family"]["event_study"]["passed"] == 0 diff --git a/tests/test_evidence_package.py b/tests/test_evidence_package.py new file mode 100644 index 0000000..cba459b --- /dev/null +++ b/tests/test_evidence_package.py @@ -0,0 +1,54 @@ +"""The run product is portable evidence, not just an HTML view.""" + +from __future__ import annotations + +import json + +from quantifact import ResearchEvidencePackage +from quantifact.cli import main + +from .conftest import QUESTION + + +def test_end_to_end_run_emits_verifiable_claim_lineage(qf, tmp_path): + report = tmp_path / "report.html" + art = qf.analyse(QUESTION, out=report, writeback=False) + + assert art.evidence_path == report.with_suffix(".evidence.json") + assert art.evidence.admitted + assert art.evidence.verify() == [] + assert art.evidence.payload["admission"]["investment_approved"] is False + assert art.evidence.payload["identity"]["execution_mode"] == "in_process" + assert art.evidence.payload["research_design"]["methodologies"] == [ + "event_study", + "historical_analogy", + ] + claim = art.evidence.payload["claims"][0] + assert claim["evidence"] + leaf = next(iter(claim["evidence"].values())) + assert leaf["code_sha256"] and leaf["value_fingerprint"] and leaf["source_series"] + assert art.evidence.payload["sources"] + assert all( + s["visible_fingerprint"] and s["license"] for s in art.evidence.payload["sources"] + ) + assert set(art.evidence.payload["code"]) == set(art.plan.names) + assert art.evidence.payload["plan"] == art.plan.to_dict() + + +def test_integrity_verification_detects_tampering(qf): + art = qf.analyse(QUESTION, writeback=False) + package = ResearchEvidencePackage(art.evidence.to_dict()) + package.payload["as_of"] = "2099-01-01" + assert "integrity hash" in " ".join(package.verify()) + + +def test_cli_verifies_a_package_and_rejects_a_mutated_one(qf, tmp_path, capsys): + path = tmp_path / "evidence.json" + qf.analyse(QUESTION, evidence_out=path, writeback=False) + assert main(["verify", str(path)]) == 0 + assert "not approved" in capsys.readouterr().out + + payload = json.loads(path.read_text()) + payload["question"] = "tampered" + path.write_text(json.dumps(payload)) + assert main(["verify", str(path)]) == 1 diff --git a/tests/test_method_contracts.py b/tests/test_method_contracts.py new file mode 100644 index 0000000..5c32dc2 --- /dev/null +++ b/tests/test_method_contracts.py @@ -0,0 +1,28 @@ +"""Domain methods fail closed instead of surviving as report prose.""" + +from __future__ import annotations + +from copy import deepcopy + +from quantifact.contracts.methods import validate_method_design, validate_method_evidence + + +def test_supported_plan_declares_and_passes_method_contracts(plan, qf): + assert plan.research_design.methodologies == ["event_study", "historical_analogy"] + assert validate_method_design(plan) == [] + art = qf.analyse(plan.question, writeback=False) + verdicts = validate_method_evidence(art.plan, art.result.frames) + assert verdicts and all(v.ok for v in verdicts) + + +def test_event_study_rejects_an_uncontracted_window(plan): + broken = deepcopy(plan) + task = broken["market_episode_returns"] + task.invariants = [x for x in task.invariants if x.get("kind") != "row_count"] + assert any("exact expected row-set" in p for p in validate_method_design(broken)) + + +def test_unknown_method_is_a_compile_error(plan): + broken = deepcopy(plan) + broken.research_design.methodologies.append("magic_alpha") + assert "unknown research methodology" in " ".join(validate_method_design(broken)) diff --git a/tests/test_process_execution.py b/tests/test_process_execution.py new file mode 100644 index 0000000..d3fd772 --- /dev/null +++ b/tests/test_process_execution.py @@ -0,0 +1,61 @@ +"""Process containment has measurable limits and never masquerades as a sandbox.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from quantifact import AnalysisPlan, ColumnSpec, ProcessExecutionHarness, Task +from quantifact.harness.cache import ValueCache +from quantifact.harness.execute import TaskExecutionError + + +def _task() -> Task: + return Task( + name="bounded", + type="table_logic", + description="one bounded value", + columns=[ColumnSpec("value", "bounded value", "float64")], + index=["value"], + row_expectation="one row", + ) + + +def test_process_worker_executes_and_returns_a_dataframe(qf, tmp_path): + task = _task() + source = "def bounded() -> pd.DataFrame:\n return pd.DataFrame({'value': [1.0]})\n" + harness = ProcessExecutionHarness( + qf.adapter, ValueCache(tmp_path / "cache"), timeout_seconds=3 + ) + result = harness.run( + AnalysisPlan("q", [task], as_of="2026-08-01"), {task.name: source} + ) + pd.testing.assert_frame_equal( + result.frames[task.name], pd.DataFrame({"value": [1.0]}) + ) + + +def test_process_worker_terminates_a_runaway_task(qf, tmp_path): + task = _task() + source = "def bounded() -> pd.DataFrame:\n while True:\n pass\n" + harness = ProcessExecutionHarness( + qf.adapter, + ValueCache(tmp_path / "cache", enabled=False), + timeout_seconds=0.2, + cpu_seconds=1, + ) + with pytest.raises(TaskExecutionError, match="exceeded"): + harness.run(AnalysisPlan("q", [task], as_of="2026-08-01"), {task.name: source}) + + +def test_quantifact_process_mode_wires_the_containment_backend(qf, tmp_path): + from quantifact import Quantifact + + isolated = Quantifact( + tmp_path / "ws", + adapter=qf.adapter, + execution_mode="process", + task_timeout_seconds=30, + ) + assert isinstance(isolated.harness, ProcessExecutionHarness) + assert isolated.execution_mode == "process" diff --git a/tests/test_quality.py b/tests/test_quality.py index 17ff85d..e649429 100644 --- a/tests/test_quality.py +++ b/tests/test_quality.py @@ -59,3 +59,12 @@ def test_critical_gate_blocks_a_high_average(): def test_rubric_weights_are_a_complete_percentage(): assert sum(c.weight for c in RUBRIC) == 100 assert len({c.id for c in RUBRIC}) == len(RUBRIC) + + +def test_repository_probe_credits_conformance_but_not_real_data_or_breadth(): + report = audit() + structured = next(r for r in report.results if r.criterion.id == "structured_data") + planning = next(r for r in report.results if r.criterion.id == "dynamic_planning") + assert structured.score == 0.75 and "conformance" in structured.evidence + assert planning.score == 0.6 and "refuse" in planning.evidence + assert structured.gap and planning.gap diff --git a/tests/test_site_architecture.py b/tests/test_site_architecture.py index f605a84..d9c79d8 100644 --- a/tests/test_site_architecture.py +++ b/tests/test_site_architecture.py @@ -34,12 +34,15 @@ def test_site_explains_architecture_workflow_and_reasoning_contract(): page = (ROOT / "site/index.html").read_text() assert "__ARCHITECTURE__" not in page and "__DATA__" not in page for phrase in ( - "Separate runtime, platform controls, and governance", + "Contain error before model output becomes investment evidence", "Investment Research System Architecture", - "Investment research workflow", + "PAT lifecycle implemented in Quantifact", "Critical investment reasoning", "governed lifecycle", "APPROVED RELEASE ARTEFACTS", + "PARTIAL · executable core", + "IMPLEMENTED · supported ops", + "MINIMUM LOOP · one effect", ): assert phrase in page diff --git a/tests/test_visible_fingerprint.py b/tests/test_visible_fingerprint.py new file mode 100644 index 0000000..41a0ad1 --- /dev/null +++ b/tests/test_visible_fingerprint.py @@ -0,0 +1,55 @@ +"""Cache and evidence identity describe the visible vintage, not future bytes.""" + +from __future__ import annotations + +import pandas as pd + + +def test_future_rows_do_not_change_an_earlier_visible_fingerprint(qf): + store = qf.adapter.store + sid = "US.OUTPUT_GAP" + cut = "2022-03-01" + before = store.fingerprint([sid], as_of=cut) + frame = store.read_frame(sid) + future = frame[frame["pub_date"] > pd.Timestamp(cut)] + assert not future.empty + + # This mirrors the fingerprint algorithm with future rows removed. If the + # implementation hashed the full file, these identities could not match. + visible = frame[frame["pub_date"] <= pd.Timestamp(cut)] + import hashlib + + h = hashlib.sha256() + h.update(cut.encode()) + h.update(sid.encode()) + h.update( + hashlib.sha256( + pd.util.hash_pandas_object(visible, index=True).to_numpy().tobytes() + ).digest() + ) + assert before == h.hexdigest()[:16] + + +def test_visible_fingerprint_changes_when_the_visible_slice_changes(qf): + sid = "US.OUTPUT_GAP" + early = qf.adapter.store.fingerprint([sid], as_of="2022-03-01") + late = qf.adapter.store.fingerprint([sid], as_of="2026-08-01") + assert early != late + + +def test_visible_fingerprint_is_memoised_per_series_and_vintage(qf, monkeypatch): + store = qf.adapter.store + sid = "US.CPI.CORE.YOY" + store._visible_fingerprints.clear() + calls = 0 + original = store.read_frame + + def counted(series_id): + nonlocal calls + calls += 1 + return original(series_id) + + monkeypatch.setattr(store, "read_frame", counted) + a = store.fingerprint([sid], as_of="2026-08-01") + b = store.fingerprint([sid], as_of="2026-08-01") + assert a == b and calls == 1 diff --git a/tools/check_release.py b/tools/check_release.py new file mode 100644 index 0000000..1cb20bf --- /dev/null +++ b/tools/check_release.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fail closed when release identity files disagree.""" + +from __future__ import annotations + +import argparse +import ast +import re +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _package_version() -> str: + tree = ast.parse((ROOT / "src/quantifact/__init__.py").read_text()) + for node in tree.body: + if ( + isinstance(node, ast.Assign) + and any( + isinstance(t, ast.Name) and t.id == "__version__" for t in node.targets + ) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + return node.value.value + raise ValueError("src/quantifact/__init__.py has no literal __version__") + + +def versions() -> dict[str, str]: + project = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"] + citation_text = (ROOT / "CITATION.cff").read_text() + match = re.search(r"^version:\s*[\"']?([^\s\"']+)", citation_text, re.MULTILINE) + if not match: + raise ValueError("CITATION.cff has no version") + return { + "pyproject": project, + "package": _package_version(), + "citation": match.group(1), + } + + +def check(tag: str | None = None) -> str: + found = versions() + unique = set(found.values()) + if len(unique) != 1: + raise SystemExit( + "release versions disagree: " + + ", ".join(f"{k}={v}" for k, v in found.items()) + ) + version = unique.pop() + changelog = (ROOT / "CHANGELOG.md").read_text() + if f"## [{version}]" not in changelog: + raise SystemExit(f"CHANGELOG.md has no release section for {version}") + if tag is not None and tag != f"v{version}": + raise SystemExit(f"tag {tag!r} does not match version v{version}") + return version + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--tag") + parser.add_argument("--print-version", action="store_true") + args = parser.parse_args() + version = check(args.tag) + if args.print_version: + print(version) + else: + print(f"release identity valid: {version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index f1ee8af..d2bdb2a 100644 --- a/uv.lock +++ b/uv.lock @@ -269,7 +269,7 @@ wheels = [ [[package]] name = "quantifact" -version = "0.2.0" +version = "0.3.0a1" source = { editable = "." } dependencies = [ { name = "numpy" },