Add `xonsh` backfill qualification workflow
Drive known-bad, fresh, restart and append checks from a private disposable NativeDB root. Deats, - bind child config and imports to the marked worktree root - preserve checksum-matched reports, snapshots and case metadata - compare timestamp coverage, seam values and persisted structure - document manual chart runs, gap triage and the expansion matrix Prompt-IO: ai/prompt-io/opencode/20260728T003247Z_ed85721c_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))backfiller_deep_fixes
parent
d7d537d531
commit
922a5df88d
|
|
@ -104,6 +104,8 @@ default.
|
||||||
Deterministic or local first-pass targets:
|
Deterministic or local first-pass targets:
|
||||||
|
|
||||||
- `tests/test_watchlists.py`
|
- `tests/test_watchlists.py`
|
||||||
|
- `tests/test_storage_audit.py`
|
||||||
|
- `tests/test_backfill_audit_snippet.py`
|
||||||
- `tests/test_accounting.py::test_account_file_default_empty`
|
- `tests/test_accounting.py::test_account_file_default_empty`
|
||||||
- `tests/test_services.py::test_runtime_boot`
|
- `tests/test_services.py::test_runtime_boot`
|
||||||
- `tests/test_services.py::test_datad_spawn`
|
- `tests/test_services.py::test_datad_spawn`
|
||||||
|
|
@ -188,6 +190,9 @@ tests/
|
||||||
test_feeds.py live Binance/Kraken feeds and shared memory
|
test_feeds.py live Binance/Kraken feeds and shared memory
|
||||||
test_questrade.py obsolete credentialed tests; skipped
|
test_questrade.py obsolete credentialed tests; skipped
|
||||||
test_services.py pikerd/datad/feed/EMS actor lifecycle
|
test_services.py pikerd/datad/feed/EMS actor lifecycle
|
||||||
|
test_storage_audit.py read-only NativeDB audit and JSON CLI
|
||||||
|
test_backfill_audit_snippet.py
|
||||||
|
disposable xonsh qualification helpers
|
||||||
test_watchlists.py deterministic watchlist JSON operations
|
test_watchlists.py deterministic watchlist JSON operations
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -196,6 +201,8 @@ tests/
|
||||||
| Changed area | Run first | Caveat |
|
| Changed area | Run first | Caveat |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `piker/watchlists/` | `tests/test_watchlists.py` | CLI suite is skipped |
|
| `piker/watchlists/` | `tests/test_watchlists.py` | CLI suite is skipped |
|
||||||
|
| `piker/storage/_audit.py`, `piker/storage/cli.py` | `tests/test_storage_audit.py` | direct Typer app, no actor |
|
||||||
|
| `snippets/nativedb_backfill_audit.xsh` | `tests/test_backfill_audit_snippet.py` | disposable paths only |
|
||||||
| `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch |
|
| `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch |
|
||||||
| `piker/accounting/` | targeted accounting node | some cases use live/configured state |
|
| `piker/accounting/` | targeted accounting node | some cases use live/configured state |
|
||||||
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | GUI/config-isolated opt-in |
|
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | GUI/config-isolated opt-in |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
provider: openai
|
||||||
|
service: opencode
|
||||||
|
session: 24fb9765-a550-4570-8350-f0fc9b7e17db
|
||||||
|
timestamp: 2026-07-28T00:32:47Z
|
||||||
|
git_ref: ed85721c
|
||||||
|
scope: code
|
||||||
|
substantive: true
|
||||||
|
raw_file: 20260728T003247Z_ed85721c_prompt_io.raw.md
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Build a safe xonsh workflow for qualifying NativeDB and reverse-backfill
|
||||||
|
correctness against a known gappy IB chart, beginning with
|
||||||
|
`mnq.cme.20260918`. Cover baseline, fresh, restart, append, and expansion
|
||||||
|
testing through implementation, adversarial review, and verification.
|
||||||
|
|
||||||
|
## Response summary
|
||||||
|
|
||||||
|
Implemented a private disposable xonsh qualification workflow. Evidence is
|
||||||
|
tied to a fixed case, report checksum, snapshot checksum, worktree import,
|
||||||
|
and phase metadata. Added explicit path and symlink safety, source
|
||||||
|
replacement handling, xonsh child environments, and lifecycle preservation
|
||||||
|
comparisons.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `snippets/nativedb_backfill_audit.xsh` - disposable qualification helpers
|
||||||
|
- `docs/manual_backfill_qualification.rst` - operator lifecycle runbook
|
||||||
|
- `tests/test_backfill_audit_snippet.py` - xonsh and comparison regressions
|
||||||
|
- `.claude/skills/run-tests/test-harness-reference.md`
|
||||||
|
- qualification test-harness guidance
|
||||||
|
- `ai/prompt-io/opencode/20260728T003247Z_ed85721c_prompt_io.raw.md`
|
||||||
|
- unedited response record
|
||||||
|
- `ai/prompt-io/opencode/20260728T003247Z_ed85721c_prompt_io.md`
|
||||||
|
- provenance metadata and response summary
|
||||||
|
|
||||||
|
## Human edits
|
||||||
|
|
||||||
|
None - generated changes have not been edited by the human.
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
model: gpt-5.6-sol
|
||||||
|
provider: openai
|
||||||
|
service: opencode
|
||||||
|
timestamp: 2026-07-28T00:32:47Z
|
||||||
|
git_ref: ed85721c
|
||||||
|
diff_cmd: git diff HEAD~1..HEAD
|
||||||
|
---
|
||||||
|
|
||||||
|
> `git diff HEAD~1..HEAD -- snippets/nativedb_backfill_audit.xsh docs/manual_backfill_qualification.rst tests/test_backfill_audit_snippet.py .claude/skills/run-tests/test-harness-reference.md`
|
||||||
|
|
||||||
|
Added sourceable xonsh helpers that create a private disposable root, bind
|
||||||
|
child processes to the reviewed worktree, capture checksum-bound phase
|
||||||
|
evidence, safely seed and clear test history, and compare lifecycle
|
||||||
|
preservation.
|
||||||
|
|
||||||
|
The workflow rejects path traversal, pre-existing symlinks, output
|
||||||
|
collisions, mismatched case/report/snapshot evidence, and same-phase
|
||||||
|
comparison. The accompanying runbook covers known-bad replay, fresh
|
||||||
|
backfill, restart, append, gap triage, and expansion cases.
|
||||||
|
|
||||||
|
Verification generated with the patch:
|
||||||
|
|
||||||
|
- xonsh qualification helper regressions: passed
|
||||||
|
- xonsh sourceability: passed
|
||||||
|
- Ruff: passed
|
||||||
|
- `git diff --check`: passed
|
||||||
|
|
||||||
|
Adversarial review drove additional evidence binding, worktree executable,
|
||||||
|
environment propagation, source replacement, and descriptor-level safety
|
||||||
|
fixes. Residual concurrent mutation risk is stated explicitly in the private
|
||||||
|
disposable-root runbook.
|
||||||
|
|
@ -0,0 +1,285 @@
|
||||||
|
Manual Backfill Qualification
|
||||||
|
=============================
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
-------
|
||||||
|
|
||||||
|
This runbook separates provider coverage, durable NativeDB state,
|
||||||
|
shared-memory hydration, and chart rendering while reproducing gappy
|
||||||
|
history. Work one FQME and timeframe at a time. Preserve the original
|
||||||
|
Parquet first, then qualify both repair of that evidence and a completely
|
||||||
|
fresh backfill.
|
||||||
|
|
||||||
|
The read-only command used throughout is::
|
||||||
|
|
||||||
|
piker store audit FQME --period 60
|
||||||
|
piker store audit FQME --period 60 --json
|
||||||
|
|
||||||
|
It reads raw Parquet directly. It does not open a storage client,
|
||||||
|
canonicalize data, publish cache state, attach SHM, or rewrite history.
|
||||||
|
Positive gaps are deliberately unclassified; an observed interval is not
|
||||||
|
automatically corruption or an expected venue closure.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Do not run qualification against normal user storage. The helper below
|
||||||
|
requires a marked disposable root before it will seed, archive, or clear
|
||||||
|
a Parquet. ``piker store anal`` and ``piker store ldshm`` are interactive
|
||||||
|
and potentially mutating; they are not audit commands.
|
||||||
|
|
||||||
|
Keep the disposable root private and do not rename, replace, or symlink
|
||||||
|
files under it while a helper is running. Stop the chart cleanly before
|
||||||
|
every audit or comparison phase.
|
||||||
|
|
||||||
|
Load The Xonsh Helpers
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
Run from the repository root in the same xonsh which will launch the chart
|
||||||
|
or daemon::
|
||||||
|
|
||||||
|
source snippets/nativedb_backfill_audit.xsh
|
||||||
|
|
||||||
|
Choose a disposable root and copy only the provider configuration needed
|
||||||
|
for the case::
|
||||||
|
|
||||||
|
run_root = p'/tmp/piker-backfill/mnq-20260918-60s'
|
||||||
|
source_config = p'~/.config/piker'.expanduser()
|
||||||
|
bfq_init(run_root, source_config)
|
||||||
|
bfq_record_case(
|
||||||
|
run_root,
|
||||||
|
fqme='mnq.cme.20260918.ib',
|
||||||
|
period_s=60,
|
||||||
|
provider='ib',
|
||||||
|
symptom='gappy chart during reverse backfill',
|
||||||
|
bad_start_utc=None,
|
||||||
|
bad_end_utc=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
``bfq_init()`` sets ``XDG_CONFIG_HOME`` in the current xonsh, so every
|
||||||
|
subsequent ``piker`` and ``pikerd`` child inherits the disposable config.
|
||||||
|
The effective storage path is under ``RUN_ROOT/xdg/piker/nativedb``.
|
||||||
|
It also records the exact repository root and ``piker`` executable in the
|
||||||
|
qualification marker, prepends that root to child ``PYTHONPATH``, and
|
||||||
|
verifies that the bound executable exposes ``piker store audit``. Audits
|
||||||
|
fail if that executable imports another tree.
|
||||||
|
|
||||||
|
The copied ``brokers.toml`` can contain credentials. Keep the run root
|
||||||
|
private and remove it manually after evidence is no longer needed.
|
||||||
|
|
||||||
|
Repository Preflight
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
Before every daemon start, verify the code and config inherited by child
|
||||||
|
processes::
|
||||||
|
|
||||||
|
git status --short --branch
|
||||||
|
git rev-parse HEAD
|
||||||
|
python -c 'import piker; print(piker.__file__)'
|
||||||
|
python -c 'import os; print(os.environ["XDG_CONFIG_HOME"])'
|
||||||
|
|
||||||
|
Stop any daemon from the previous phase cleanly. Use a separate registry
|
||||||
|
address in the disposable ``conf.toml`` when a normal Piker stack remains
|
||||||
|
active.
|
||||||
|
|
||||||
|
Known-Bad Replay
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Copy the original file; never move it out of normal storage::
|
||||||
|
|
||||||
|
fqme = 'mnq.cme.20260918.ib'
|
||||||
|
period_s = 60
|
||||||
|
source_parquet = (
|
||||||
|
p'~/.config/piker/nativedb'.expanduser()
|
||||||
|
/f'{fqme}.ohlcv{period_s}s.parquet'
|
||||||
|
)
|
||||||
|
bfq_seed(run_root, fqme, period_s, source_parquet)
|
||||||
|
bfq_audit(run_root, 'baseline', fqme, period_s)
|
||||||
|
|
||||||
|
The baseline phase writes::
|
||||||
|
|
||||||
|
evidence/baseline.json
|
||||||
|
evidence/baseline.parquet
|
||||||
|
evidence/baseline.manual.json
|
||||||
|
|
||||||
|
The JSON checksum must match the read-only snapshot, and the manual sidecar
|
||||||
|
binds that report to its phase and case. Comparison rechecks those bindings
|
||||||
|
before reading either phase. The report records raw schema, row and
|
||||||
|
timestamp counts, non-finite values, ordering, duplicate excess, canonical
|
||||||
|
index state, and every retained positive-gap detail. Files with invalid
|
||||||
|
Parquet encoding can not produce a JSON report, but ``--snapshot`` still
|
||||||
|
preserves their exact bytes before parse failure.
|
||||||
|
|
||||||
|
Launch the chart manually from the same shell and capture logs::
|
||||||
|
|
||||||
|
piker -l info chart mnq.cme.20260918.ib \
|
||||||
|
2>&1 | tee @(run_root / 'logs' / 'repair-chart.log')
|
||||||
|
|
||||||
|
After a clean stop::
|
||||||
|
|
||||||
|
bfq_audit(
|
||||||
|
run_root,
|
||||||
|
'repair',
|
||||||
|
fqme,
|
||||||
|
period_s,
|
||||||
|
require_structural=True,
|
||||||
|
)
|
||||||
|
bfq_compare(run_root, 'baseline', 'repair')
|
||||||
|
|
||||||
|
The comparison fails if structural validity regresses, any original
|
||||||
|
timestamp disappears, chronological endpoints shrink, timestamps become
|
||||||
|
duplicated or unordered, indexes cease to be canonical, or OHLCV becomes
|
||||||
|
non-finite. Provider replacement of matching seam rows is reported
|
||||||
|
separately as ``changed_common_timestamps`` for operator review.
|
||||||
|
|
||||||
|
Fresh Backfill
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Yes, fresh qualification means saving the known-bad evidence and then
|
||||||
|
starting with no active Parquet in the disposable NativeDB. Archive only
|
||||||
|
the disposable file::
|
||||||
|
|
||||||
|
bfq_clear_for_fresh(run_root, fqme, period_s)
|
||||||
|
|
||||||
|
Confirm the printed active path is absent, launch the same chart, wait for
|
||||||
|
bounded provider exhaustion or the intended coverage, then stop cleanly::
|
||||||
|
|
||||||
|
bfq_audit(
|
||||||
|
run_root,
|
||||||
|
'fresh',
|
||||||
|
fqme,
|
||||||
|
period_s,
|
||||||
|
require_structural=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Do not compare ``baseline`` to ``fresh`` as a timestamp-preservation gate:
|
||||||
|
provider limits may intentionally produce a different initial extent.
|
||||||
|
Treat ``fresh`` as the baseline for restart and append qualification.
|
||||||
|
|
||||||
|
Restart And Append
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Restart the same chart against the fresh persisted file, stop cleanly, and
|
||||||
|
record::
|
||||||
|
|
||||||
|
bfq_audit(
|
||||||
|
run_root,
|
||||||
|
'restart',
|
||||||
|
fqme,
|
||||||
|
period_s,
|
||||||
|
require_structural=True,
|
||||||
|
)
|
||||||
|
bfq_compare(run_root, 'fresh', 'restart')
|
||||||
|
|
||||||
|
Leave a subsequent run active long enough to append new samples, stop, and
|
||||||
|
record::
|
||||||
|
|
||||||
|
bfq_audit(
|
||||||
|
run_root,
|
||||||
|
'append',
|
||||||
|
fqme,
|
||||||
|
period_s,
|
||||||
|
require_structural=True,
|
||||||
|
)
|
||||||
|
bfq_compare(
|
||||||
|
run_root,
|
||||||
|
'restart',
|
||||||
|
'append',
|
||||||
|
require_newer=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
The comparison JSON reports gaps added and removed. Expected closures stay
|
||||||
|
visible until a venue-aware classifier proves their session alignment.
|
||||||
|
|
||||||
|
Gap Layer Triage
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Use the persisted report and chart together:
|
||||||
|
|
||||||
|
=============================== =========================================
|
||||||
|
Observation Suspected layer
|
||||||
|
=============================== =========================================
|
||||||
|
Parquet contiguous, chart gappy SHM hydration, sampling, or chart render
|
||||||
|
Parquet gap, provider has bars provider-delta or storage merge
|
||||||
|
Provider and Parquet both gappy provider omission or venue closure
|
||||||
|
Gap vanishes after restart SHM publication or cache lifecycle
|
||||||
|
Old timestamps disappear destructive persistence regression
|
||||||
|
Newest seam duplicates conflict or dedupe policy
|
||||||
|
=============================== =========================================
|
||||||
|
|
||||||
|
Always record exact UTC gap endpoints. The audit's numeric epoch is
|
||||||
|
authoritative; the ``*_utc`` fields are deterministic display values.
|
||||||
|
|
||||||
|
Expansion Matrix
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Expand only after the fixed FQME passes fresh, restart, and append. Use a
|
||||||
|
new disposable root and case record for every row:
|
||||||
|
|
||||||
|
==================== ========== ========================================
|
||||||
|
Case Period Coverage model
|
||||||
|
==================== ========== ========================================
|
||||||
|
Original IB future 60 seconds CME maintenance and weekend closures
|
||||||
|
Original IB future 1 second high-frequency provider limits
|
||||||
|
Second CME future 60 seconds contract-specific history
|
||||||
|
IB equity 60 seconds regular overnight and weekend closure
|
||||||
|
IB FX 60 seconds 24x5 session behavior
|
||||||
|
Crypto spot/perp 60 seconds continuous-session control
|
||||||
|
Expired future 60 seconds history-only and expiry behavior
|
||||||
|
==================== ========== ========================================
|
||||||
|
|
||||||
|
A sourceable matrix can remain ordinary Python data in xonsh::
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
{
|
||||||
|
'name': 'mnq-60s',
|
||||||
|
'fqme': 'mnq.cme.20260918.ib',
|
||||||
|
'period_s': 60,
|
||||||
|
'provider': 'ib',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'mnq-1s',
|
||||||
|
'fqme': 'mnq.cme.20260918.ib',
|
||||||
|
'period_s': 1,
|
||||||
|
'provider': 'ib',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for case in cases:
|
||||||
|
case_root = p'/tmp/piker-backfill' / case['name']
|
||||||
|
bfq_init(case_root, source_config)
|
||||||
|
bfq_record_case(
|
||||||
|
case_root,
|
||||||
|
fqme=case['fqme'],
|
||||||
|
period_s=case['period_s'],
|
||||||
|
provider=case['provider'],
|
||||||
|
symptom='manual expansion case',
|
||||||
|
)
|
||||||
|
|
||||||
|
This loop prepares isolated case roots only. Run the complete seed or
|
||||||
|
fresh, chart, audit, restart, append, and compare lifecycle in each root;
|
||||||
|
directory creation alone is not qualification.
|
||||||
|
|
||||||
|
The chart path may allocate companion 1-second and 60-second files. Audit
|
||||||
|
the selected case path explicitly and retain companion files as secondary
|
||||||
|
evidence; do not imply that the live request isolated one timeframe.
|
||||||
|
|
||||||
|
Interruption Qualification
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
After normal restart is stable, interrupt one phase with graceful Ctrl-C,
|
||||||
|
restart, and compare against the last clean evidence. Provider disconnect,
|
||||||
|
SIGTERM, and deterministic write-barrier failures come next. Avoid timing a
|
||||||
|
SIGKILL by hand; add an explicit failpoint before qualifying atomic replace
|
||||||
|
boundaries.
|
||||||
|
|
||||||
|
Result Interpretation
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
``structural_ok`` means the raw persisted file satisfies durable schema,
|
||||||
|
finite values, positive unique ordered timestamps, canonical index, and
|
||||||
|
period-aligned gaps. ``gap_free`` means no positive gaps were observed when
|
||||||
|
at least two valid timestamps made cadence verifiable; otherwise it is
|
||||||
|
``null``. ``qualification_ok`` combines both and is intentionally strict.
|
||||||
|
|
||||||
|
A structurally valid session-market file can have ``gap_free = false``.
|
||||||
|
Those intervals require provider/session evidence before being marked as
|
||||||
|
expected closures. Unknown calendar state must stay unknown.
|
||||||
|
|
@ -0,0 +1,936 @@
|
||||||
|
#!env xonsh
|
||||||
|
'''
|
||||||
|
Sourceable helpers for disposable NativeDB backfill qualification.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
source snippets/nativedb_backfill_audit.xsh
|
||||||
|
|
||||||
|
Every mutating helper requires a marker created by ``bfq_init()`` and
|
||||||
|
refuses to operate outside that disposable root.
|
||||||
|
Keep the private run root free from concurrent manual changes.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from datetime import (
|
||||||
|
UTC,
|
||||||
|
datetime,
|
||||||
|
)
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
|
||||||
|
_bfq_marker: str = '.piker-backfill-qualification'
|
||||||
|
_bfq_schema: str = 'piker.backfill-qualification/v1'
|
||||||
|
_bfq_audit_schema: str = 'piker.nativedb.audit/v1'
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_process_env() -> dict[str, str]:
|
||||||
|
'''
|
||||||
|
Return the effective environment inherited by xonsh children.
|
||||||
|
|
||||||
|
'''
|
||||||
|
env: dict[str, str] = os.environ.copy()
|
||||||
|
try:
|
||||||
|
from xonsh.built_ins import XSH
|
||||||
|
except ImportError:
|
||||||
|
return env
|
||||||
|
if XSH.env is not None:
|
||||||
|
env.update(XSH.env.detype())
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_setenv(name: str, value: str) -> None:
|
||||||
|
'''
|
||||||
|
Update both Python and xonsh subprocess environments.
|
||||||
|
|
||||||
|
'''
|
||||||
|
os.environ[name] = value
|
||||||
|
try:
|
||||||
|
from xonsh.built_ins import XSH
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
if XSH.env is not None:
|
||||||
|
XSH.env[name] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_metadata(root: Path) -> dict:
|
||||||
|
marker: Path = root / _bfq_marker
|
||||||
|
if (
|
||||||
|
not marker.is_file()
|
||||||
|
or
|
||||||
|
marker.is_symlink()
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Qualification marker missing or unsafe: {marker}'
|
||||||
|
)
|
||||||
|
metadata: dict = json.loads(marker.read_text())
|
||||||
|
if metadata.get('schema') != _bfq_schema:
|
||||||
|
raise RuntimeError(f'Unknown qualification marker: {marker}')
|
||||||
|
if Path(metadata['root']).resolve() != root:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Qualification marker root mismatch: {marker}'
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_root(run_root: str|Path) -> Path:
|
||||||
|
root = Path(run_root).expanduser().resolve()
|
||||||
|
_bfq_metadata(root)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_fqme(fqme: str) -> str:
|
||||||
|
if (
|
||||||
|
fqme in {'.', '..'}
|
||||||
|
or
|
||||||
|
Path(fqme).name != fqme
|
||||||
|
or
|
||||||
|
'/' in fqme
|
||||||
|
or
|
||||||
|
'\\' in fqme
|
||||||
|
):
|
||||||
|
raise ValueError(f'Unsafe FQME path component: {fqme!r}')
|
||||||
|
return fqme
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_paths(
|
||||||
|
run_root: str|Path,
|
||||||
|
fqme: str,
|
||||||
|
period_s: int,
|
||||||
|
|
||||||
|
) -> tuple[Path, Path, Path, Path]:
|
||||||
|
root: Path = _bfq_root(run_root)
|
||||||
|
fqme = _bfq_fqme(fqme)
|
||||||
|
xdg: Path = root / 'xdg'
|
||||||
|
evidence: Path = root / 'evidence'
|
||||||
|
nativedb: Path = xdg / 'piker' / 'nativedb'
|
||||||
|
parquet: Path = nativedb / f'{fqme}.ohlcv{period_s}s.parquet'
|
||||||
|
for path in (xdg, evidence, nativedb, parquet):
|
||||||
|
resolved: Path = path.resolve()
|
||||||
|
if not resolved.is_relative_to(root):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Qualification path escaped run root: {resolved}'
|
||||||
|
)
|
||||||
|
if path.is_symlink():
|
||||||
|
raise RuntimeError(f'Refusing symlinked path: {path}')
|
||||||
|
return root, xdg, evidence, parquet
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_hash_file(file: BinaryIO) -> str:
|
||||||
|
digest = sha256()
|
||||||
|
file.seek(0)
|
||||||
|
for chunk in iter(
|
||||||
|
lambda: file.read(1024 * 1024),
|
||||||
|
b'',
|
||||||
|
):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_hash(path: Path) -> str:
|
||||||
|
with path.open('rb') as file:
|
||||||
|
return _bfq_hash_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_read_bytes(path: Path) -> bytes:
|
||||||
|
descriptor: int = os.open(
|
||||||
|
path,
|
||||||
|
os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
|
||||||
|
)
|
||||||
|
with os.fdopen(descriptor, 'rb') as file:
|
||||||
|
return file.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_copy_exclusive(
|
||||||
|
source: Path,
|
||||||
|
destination: Path,
|
||||||
|
mode: int|None = None,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Copy bytes to a new non-symlink path without replacement.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Refusing symlinked output: {destination}'
|
||||||
|
)
|
||||||
|
created: bool = False
|
||||||
|
try:
|
||||||
|
with source.open('rb') as source_file:
|
||||||
|
with destination.open('xb') as destination_file:
|
||||||
|
created = True
|
||||||
|
if mode is not None:
|
||||||
|
os.fchmod(destination_file.fileno(), mode)
|
||||||
|
shutil.copyfileobj(source_file, destination_file)
|
||||||
|
except BaseException:
|
||||||
|
if created:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _bfq_phase(phase: str) -> str:
|
||||||
|
safe: str = phase.replace('-', '').replace('_', '')
|
||||||
|
if not safe.isalnum():
|
||||||
|
raise ValueError(
|
||||||
|
'Phase names may contain letters, numbers, - and _ only'
|
||||||
|
)
|
||||||
|
return phase
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_init(
|
||||||
|
run_root: str|Path,
|
||||||
|
source_config: str|Path|None = None,
|
||||||
|
validate_piker: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
'''
|
||||||
|
Create a disposable XDG root and optionally copy provider config.
|
||||||
|
|
||||||
|
'''
|
||||||
|
root = Path(run_root).expanduser().resolve()
|
||||||
|
marker: Path = root / _bfq_marker
|
||||||
|
marker_exists: bool = (
|
||||||
|
marker.exists()
|
||||||
|
or
|
||||||
|
marker.is_symlink()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
root.exists()
|
||||||
|
and
|
||||||
|
not marker_exists
|
||||||
|
and
|
||||||
|
any(root.iterdir())
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Refusing non-empty unmarked directory: {root}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if marker_exists:
|
||||||
|
metadata: dict = _bfq_metadata(root)
|
||||||
|
else:
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
xdg: Path = root / 'xdg'
|
||||||
|
piker_conf: Path = xdg / 'piker'
|
||||||
|
nativedb: Path = piker_conf / 'nativedb'
|
||||||
|
evidence: Path = root / 'evidence'
|
||||||
|
logs: Path = root / 'logs'
|
||||||
|
for path in (root, xdg, piker_conf, nativedb, evidence, logs):
|
||||||
|
if path.is_symlink():
|
||||||
|
raise RuntimeError(f'Refusing symlinked path: {path}')
|
||||||
|
if not path.resolve().is_relative_to(root):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Path escaped qualification root: {path}'
|
||||||
|
)
|
||||||
|
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
path.chmod(0o700)
|
||||||
|
|
||||||
|
if not marker_exists:
|
||||||
|
process_env: dict[str, str] = _bfq_process_env()
|
||||||
|
repo_root = subprocess.run(
|
||||||
|
['git', 'rev-parse', '--show-toplevel'],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=process_env,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
piker_executable: str|None = shutil.which(
|
||||||
|
'piker',
|
||||||
|
path=process_env.get('PATH'),
|
||||||
|
)
|
||||||
|
if piker_executable is None:
|
||||||
|
raise RuntimeError('No `piker` executable found in PATH')
|
||||||
|
marker_data: dict = {
|
||||||
|
'schema': _bfq_schema,
|
||||||
|
'root': str(root),
|
||||||
|
'repo_root': str(Path(repo_root).resolve()),
|
||||||
|
'piker_executable': str(
|
||||||
|
Path(piker_executable).resolve()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
metadata = marker_data
|
||||||
|
|
||||||
|
if validate_piker:
|
||||||
|
process_env = _bfq_process_env()
|
||||||
|
repo_root = metadata['repo_root']
|
||||||
|
pythonpath: list[str] = [repo_root]
|
||||||
|
pythonpath.extend(
|
||||||
|
path
|
||||||
|
for path in process_env.get('PYTHONPATH', '').split(
|
||||||
|
os.pathsep
|
||||||
|
)
|
||||||
|
if path and path != repo_root
|
||||||
|
)
|
||||||
|
process_env['PYTHONPATH'] = os.pathsep.join(pythonpath)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
metadata['piker_executable'],
|
||||||
|
'store',
|
||||||
|
'audit',
|
||||||
|
'--help',
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
cwd=metadata['repo_root'],
|
||||||
|
env=process_env,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Bound `piker` lacks `store audit`:\n'
|
||||||
|
f'{metadata["piker_executable"]}\n'
|
||||||
|
f'{proc.stderr}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not marker_exists:
|
||||||
|
with marker.open('x') as marker_file:
|
||||||
|
os.fchmod(marker_file.fileno(), 0o600)
|
||||||
|
marker_file.write(
|
||||||
|
json.dumps(metadata, indent=2, sort_keys=True)
|
||||||
|
+'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_config is not None:
|
||||||
|
source = Path(source_config).expanduser().resolve()
|
||||||
|
for name in ('conf.toml', 'brokers.toml'):
|
||||||
|
src: Path = source / name
|
||||||
|
dst: Path = piker_conf / name
|
||||||
|
if src.is_file() and not dst.exists():
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
if name == 'brokers.toml':
|
||||||
|
with dst.open('rb') as config_file:
|
||||||
|
os.fchmod(config_file.fileno(), 0o600)
|
||||||
|
|
||||||
|
bfq_use(root)
|
||||||
|
print(
|
||||||
|
f'Disposable qualification root ready:\n'
|
||||||
|
f' root: {root}\n'
|
||||||
|
f' XDG_CONFIG_HOME: {root / "xdg"}\n'
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_use(run_root: str|Path) -> Path:
|
||||||
|
'''
|
||||||
|
Point xonsh and future children at the disposable root.
|
||||||
|
|
||||||
|
'''
|
||||||
|
root: Path = _bfq_root(run_root)
|
||||||
|
metadata: dict = _bfq_metadata(root)
|
||||||
|
xdg: Path = root / 'xdg'
|
||||||
|
repo_root: str = metadata['repo_root']
|
||||||
|
process_env: dict[str, str] = _bfq_process_env()
|
||||||
|
pythonpath: list[str] = [repo_root]
|
||||||
|
pythonpath.extend(
|
||||||
|
path
|
||||||
|
for path in process_env.get('PYTHONPATH', '').split(
|
||||||
|
os.pathsep
|
||||||
|
)
|
||||||
|
if path and path != repo_root
|
||||||
|
)
|
||||||
|
_bfq_setenv('XDG_CONFIG_HOME', str(xdg))
|
||||||
|
_bfq_setenv('PYTHONPATH', os.pathsep.join(pythonpath))
|
||||||
|
print(f'XDG_CONFIG_HOME={xdg}')
|
||||||
|
return xdg
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_record_case(
|
||||||
|
run_root: str|Path,
|
||||||
|
fqme: str,
|
||||||
|
period_s: int,
|
||||||
|
provider: str,
|
||||||
|
symptom: str,
|
||||||
|
bad_start_utc: str|None = None,
|
||||||
|
bad_end_utc: str|None = None,
|
||||||
|
) -> Path:
|
||||||
|
'''
|
||||||
|
Save read-only operator inputs used to identify one case.
|
||||||
|
|
||||||
|
'''
|
||||||
|
root: Path = _bfq_root(run_root)
|
||||||
|
fqme = _bfq_fqme(fqme)
|
||||||
|
path: Path = root / 'case.json'
|
||||||
|
if path.is_symlink():
|
||||||
|
raise RuntimeError(f'Refusing symlinked case record: {path}')
|
||||||
|
if path.exists():
|
||||||
|
raise FileExistsError(f'Case record already exists: {path}')
|
||||||
|
case: dict = {
|
||||||
|
'fqme': fqme,
|
||||||
|
'period_s': period_s,
|
||||||
|
'provider': provider,
|
||||||
|
'symptom': symptom,
|
||||||
|
'bad_start_utc': bad_start_utc,
|
||||||
|
'bad_end_utc': bad_end_utc,
|
||||||
|
'created_at_utc': datetime.now(tz=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
with path.open('x') as case_file:
|
||||||
|
os.fchmod(case_file.fileno(), 0o444)
|
||||||
|
case_file.write(
|
||||||
|
json.dumps(case, indent=2, sort_keys=True) + '\n'
|
||||||
|
)
|
||||||
|
print(f'Wrote case record: {path}')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_seed(
|
||||||
|
run_root: str|Path,
|
||||||
|
fqme: str,
|
||||||
|
period_s: int,
|
||||||
|
source_parquet: str|Path,
|
||||||
|
) -> Path:
|
||||||
|
'''
|
||||||
|
Copy a known-bad Parquet into disposable storage and evidence.
|
||||||
|
|
||||||
|
'''
|
||||||
|
(
|
||||||
|
root,
|
||||||
|
_,
|
||||||
|
evidence,
|
||||||
|
parquet,
|
||||||
|
) = _bfq_paths(run_root, fqme, period_s)
|
||||||
|
source = Path(source_parquet).expanduser().resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise FileNotFoundError(source)
|
||||||
|
if parquet.exists():
|
||||||
|
raise FileExistsError(parquet)
|
||||||
|
|
||||||
|
evidence_seed: Path = evidence / 'seed.parquet'
|
||||||
|
if evidence_seed.exists():
|
||||||
|
raise FileExistsError(evidence_seed)
|
||||||
|
_bfq_copy_exclusive(source, evidence_seed, mode=0o444)
|
||||||
|
_bfq_copy_exclusive(evidence_seed, parquet)
|
||||||
|
print(
|
||||||
|
f'Seeded disposable NativeDB:\n'
|
||||||
|
f' source: {source}\n'
|
||||||
|
f' evidence: {evidence_seed}\n'
|
||||||
|
f' active: {parquet}\n'
|
||||||
|
f' root: {root}\n'
|
||||||
|
)
|
||||||
|
return parquet
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_clear_for_fresh(
|
||||||
|
run_root: str|Path,
|
||||||
|
fqme: str,
|
||||||
|
period_s: int,
|
||||||
|
) -> Path|None:
|
||||||
|
'''
|
||||||
|
Remove only disposable active history after archiving it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
(
|
||||||
|
_,
|
||||||
|
_,
|
||||||
|
evidence,
|
||||||
|
parquet,
|
||||||
|
) = _bfq_paths(run_root, fqme, period_s)
|
||||||
|
if not parquet.exists():
|
||||||
|
print(f'Already fresh; no active Parquet: {parquet}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
stamp: str = datetime.now(tz=UTC).strftime(
|
||||||
|
'%Y%m%dT%H%M%S%fZ'
|
||||||
|
)
|
||||||
|
archived: Path = evidence / f'pre-fresh-{stamp}.parquet'
|
||||||
|
if (
|
||||||
|
archived.exists()
|
||||||
|
or
|
||||||
|
archived.is_symlink()
|
||||||
|
):
|
||||||
|
raise FileExistsError(archived)
|
||||||
|
os.link(parquet, archived)
|
||||||
|
with archived.open('rb') as archived_file:
|
||||||
|
os.fchmod(archived_file.fileno(), 0o444)
|
||||||
|
parquet.unlink()
|
||||||
|
print(
|
||||||
|
f'Archived disposable history for fresh backfill:\n'
|
||||||
|
f' archived: {archived}\n'
|
||||||
|
f' active path now absent: {parquet}\n'
|
||||||
|
)
|
||||||
|
return archived
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_audit(
|
||||||
|
run_root: str|Path,
|
||||||
|
phase: str,
|
||||||
|
fqme: str,
|
||||||
|
period_s: int,
|
||||||
|
max_gaps: int = 200,
|
||||||
|
require_structural: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
'''
|
||||||
|
Save JSON plus a checksum-matched Parquet snapshot for one phase.
|
||||||
|
|
||||||
|
'''
|
||||||
|
phase = _bfq_phase(phase)
|
||||||
|
(
|
||||||
|
root,
|
||||||
|
_,
|
||||||
|
evidence,
|
||||||
|
parquet,
|
||||||
|
) = _bfq_paths(run_root, fqme, period_s)
|
||||||
|
bfq_use(root)
|
||||||
|
if not parquet.is_file():
|
||||||
|
raise FileNotFoundError(parquet)
|
||||||
|
|
||||||
|
report_path: Path = evidence / f'{phase}.json'
|
||||||
|
snapshot_path: Path = evidence / f'{phase}.parquet'
|
||||||
|
manual_path: Path = evidence / f'{phase}.manual.json'
|
||||||
|
if any(
|
||||||
|
path.exists()
|
||||||
|
for path in (report_path, snapshot_path, manual_path)
|
||||||
|
):
|
||||||
|
raise FileExistsError(
|
||||||
|
f'Phase evidence already exists: {phase}'
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata: dict = _bfq_metadata(root)
|
||||||
|
piker_cmd: str = metadata['piker_executable']
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
piker_cmd,
|
||||||
|
'store',
|
||||||
|
'audit',
|
||||||
|
fqme,
|
||||||
|
'--period',
|
||||||
|
str(period_s),
|
||||||
|
'--max-gaps',
|
||||||
|
str(max_gaps),
|
||||||
|
'--output',
|
||||||
|
str(report_path),
|
||||||
|
'--snapshot',
|
||||||
|
str(snapshot_path),
|
||||||
|
'--json',
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
env=_bfq_process_env(),
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if proc.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'`piker store audit` failed ({proc.returncode}):\n'
|
||||||
|
f'{proc.stderr}'
|
||||||
|
)
|
||||||
|
report: dict = json.loads(report_path.read_text())
|
||||||
|
report_hash: str = _bfq_hash(report_path)
|
||||||
|
snapshot_hash: str = _bfq_hash(snapshot_path)
|
||||||
|
if snapshot_hash != report['source']['sha256']:
|
||||||
|
raise RuntimeError('Audit snapshot checksum mismatch')
|
||||||
|
|
||||||
|
git_head = subprocess.run(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
cwd=metadata['repo_root'],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
piker_file = Path(report['runtime']['piker_file']).resolve()
|
||||||
|
repo_root = Path(metadata['repo_root']).resolve()
|
||||||
|
if not piker_file.is_relative_to(repo_root):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'`piker` imported outside reviewed repo: {piker_file}'
|
||||||
|
)
|
||||||
|
manual: dict = {
|
||||||
|
'phase': phase,
|
||||||
|
'run_root': str(root),
|
||||||
|
'snapshot_path': snapshot_path.name,
|
||||||
|
'report_sha256': report_hash,
|
||||||
|
'git_head': git_head,
|
||||||
|
'piker_file': str(piker_file),
|
||||||
|
'piker_executable': piker_cmd,
|
||||||
|
'xdg_config_home': os.environ['XDG_CONFIG_HOME'],
|
||||||
|
}
|
||||||
|
with manual_path.open('x') as manual_file:
|
||||||
|
os.fchmod(manual_file.fileno(), 0o444)
|
||||||
|
manual_file.write(
|
||||||
|
json.dumps(manual, indent=2, sort_keys=True) + '\n'
|
||||||
|
)
|
||||||
|
with report_path.open('rb') as report_file:
|
||||||
|
os.fchmod(report_file.fileno(), 0o444)
|
||||||
|
|
||||||
|
result: dict = report['result']
|
||||||
|
print(
|
||||||
|
f'Audit saved: {report_path}\n'
|
||||||
|
f' snapshot: {snapshot_path}\n'
|
||||||
|
f' rows: {report["rows"]["count"]}\n'
|
||||||
|
f' structural_ok: {result["structural_ok"]}\n'
|
||||||
|
f' gaps: {report["gaps"]["count"]}\n'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
require_structural
|
||||||
|
and
|
||||||
|
not result['structural_ok']
|
||||||
|
):
|
||||||
|
raise AssertionError(
|
||||||
|
f'Phase is structurally invalid: {report_path}'
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def bfq_compare(
|
||||||
|
run_root: str|Path,
|
||||||
|
before_phase: str,
|
||||||
|
after_phase: str,
|
||||||
|
require_newer: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
'''
|
||||||
|
Compare evidence and fail on core preservation regressions.
|
||||||
|
|
||||||
|
'''
|
||||||
|
before_phase = _bfq_phase(before_phase)
|
||||||
|
after_phase = _bfq_phase(after_phase)
|
||||||
|
if before_phase == after_phase:
|
||||||
|
raise ValueError('Comparison phases must differ')
|
||||||
|
root: Path = _bfq_root(run_root)
|
||||||
|
evidence: Path = root / 'evidence'
|
||||||
|
if (
|
||||||
|
evidence.is_symlink()
|
||||||
|
or
|
||||||
|
not evidence.is_dir()
|
||||||
|
or
|
||||||
|
not evidence.resolve().is_relative_to(root)
|
||||||
|
):
|
||||||
|
raise RuntimeError(f'Unsafe evidence directory: {evidence}')
|
||||||
|
case_path: Path = root / 'case.json'
|
||||||
|
if (
|
||||||
|
case_path.is_symlink()
|
||||||
|
or
|
||||||
|
not case_path.is_file()
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Qualification case missing: {case_path}'
|
||||||
|
)
|
||||||
|
case: dict = json.loads(case_path.read_text())
|
||||||
|
|
||||||
|
def load_phase(phase: str) -> tuple[dict, pl.DataFrame]:
|
||||||
|
report_path: Path = evidence / f'{phase}.json'
|
||||||
|
snapshot_path: Path = evidence / f'{phase}.parquet'
|
||||||
|
manual_path: Path = evidence / f'{phase}.manual.json'
|
||||||
|
for path in (report_path, snapshot_path, manual_path):
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or
|
||||||
|
not path.is_file()
|
||||||
|
or
|
||||||
|
not path.resolve().is_relative_to(root)
|
||||||
|
):
|
||||||
|
raise RuntimeError(f'Unsafe phase evidence: {path}')
|
||||||
|
|
||||||
|
report_bytes: bytes = _bfq_read_bytes(report_path)
|
||||||
|
manual_bytes: bytes = _bfq_read_bytes(manual_path)
|
||||||
|
report: dict = json.loads(report_bytes)
|
||||||
|
manual: dict = json.loads(manual_bytes)
|
||||||
|
if report.get('audit_schema') != _bfq_audit_schema:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Unknown audit schema: {report_path}'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
report.get('fqme') != case.get('fqme')
|
||||||
|
or
|
||||||
|
report.get('period_s') != case.get('period_s')
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Phase does not match qualification case: {phase}'
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
manual.get('phase') != phase
|
||||||
|
or
|
||||||
|
manual.get('run_root') != str(root)
|
||||||
|
or
|
||||||
|
manual.get('snapshot_path') != snapshot_path.name
|
||||||
|
):
|
||||||
|
raise RuntimeError(f'Manual metadata mismatch: {phase}')
|
||||||
|
report_hash: str = sha256(report_bytes).hexdigest()
|
||||||
|
if report_hash != manual.get('report_sha256'):
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Audit report checksum mismatch: {phase}'
|
||||||
|
)
|
||||||
|
descriptor: int = os.open(
|
||||||
|
snapshot_path,
|
||||||
|
os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
|
||||||
|
)
|
||||||
|
with os.fdopen(descriptor, 'rb') as snapshot_file:
|
||||||
|
snapshot_stat = os.fstat(snapshot_file.fileno())
|
||||||
|
snapshot_hash: str = _bfq_hash_file(snapshot_file)
|
||||||
|
if snapshot_hash != report['source']['sha256']:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Audit snapshot checksum mismatch: {phase}'
|
||||||
|
)
|
||||||
|
source_size: int = report['source']['size_bytes']
|
||||||
|
if snapshot_stat.st_size != source_size:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Audit snapshot size mismatch: {phase}'
|
||||||
|
)
|
||||||
|
snapshot_file.seek(0)
|
||||||
|
frame: pl.DataFrame = pl.read_parquet(snapshot_file)
|
||||||
|
return report, frame
|
||||||
|
|
||||||
|
before, before_frame = load_phase(before_phase)
|
||||||
|
after, after_frame = load_phase(after_phase)
|
||||||
|
|
||||||
|
limitations: list[str] = []
|
||||||
|
|
||||||
|
def valid_timestamps(
|
||||||
|
frame: pl.DataFrame,
|
||||||
|
label: str,
|
||||||
|
) -> set|None:
|
||||||
|
if 'time' not in frame.columns:
|
||||||
|
limitations.append(f'{label}_time_missing')
|
||||||
|
return None
|
||||||
|
time_dtype = frame['time'].dtype
|
||||||
|
if not (
|
||||||
|
time_dtype.is_integer()
|
||||||
|
or
|
||||||
|
time_dtype.is_float()
|
||||||
|
):
|
||||||
|
limitations.append(f'{label}_time_nonnumeric')
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamps: set = set()
|
||||||
|
for timestamp in frame['time'].drop_nulls().to_list():
|
||||||
|
if (
|
||||||
|
isinstance(timestamp, float)
|
||||||
|
and
|
||||||
|
not np.isfinite(timestamp)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if timestamp <= 0:
|
||||||
|
continue
|
||||||
|
timestamps.add(timestamp)
|
||||||
|
return timestamps
|
||||||
|
|
||||||
|
before_times: set|None = valid_timestamps(
|
||||||
|
before_frame,
|
||||||
|
'before',
|
||||||
|
)
|
||||||
|
after_times: set|None = valid_timestamps(
|
||||||
|
after_frame,
|
||||||
|
'after',
|
||||||
|
)
|
||||||
|
missing_times: list = []
|
||||||
|
if (
|
||||||
|
before_times is not None
|
||||||
|
and
|
||||||
|
after_times is not None
|
||||||
|
):
|
||||||
|
missing_times = sorted(before_times - after_times)
|
||||||
|
|
||||||
|
value_fields: list[str] = [
|
||||||
|
'open',
|
||||||
|
'high',
|
||||||
|
'low',
|
||||||
|
'close',
|
||||||
|
'volume',
|
||||||
|
]
|
||||||
|
changed_times: list|None = None
|
||||||
|
seam_columns: set[str] = set(value_fields)
|
||||||
|
seam_columns.add('time')
|
||||||
|
seam_ready: bool = bool(
|
||||||
|
seam_columns.issubset(before_frame.columns)
|
||||||
|
and
|
||||||
|
seam_columns.issubset(after_frame.columns)
|
||||||
|
and
|
||||||
|
before_times is not None
|
||||||
|
and
|
||||||
|
after_times is not None
|
||||||
|
)
|
||||||
|
if seam_ready:
|
||||||
|
before_latest = before_frame.unique(
|
||||||
|
subset='time',
|
||||||
|
keep='last',
|
||||||
|
).filter(
|
||||||
|
pl.col('time').is_not_null()
|
||||||
|
&
|
||||||
|
pl.col('time').is_finite()
|
||||||
|
&
|
||||||
|
(pl.col('time') > 0)
|
||||||
|
).select([
|
||||||
|
'time',
|
||||||
|
pl.struct(value_fields).hash().alias('value_hash'),
|
||||||
|
])
|
||||||
|
after_latest = after_frame.unique(
|
||||||
|
subset='time',
|
||||||
|
keep='last',
|
||||||
|
).filter(
|
||||||
|
pl.col('time').is_not_null()
|
||||||
|
&
|
||||||
|
pl.col('time').is_finite()
|
||||||
|
&
|
||||||
|
(pl.col('time') > 0)
|
||||||
|
).select([
|
||||||
|
'time',
|
||||||
|
pl.struct(value_fields).hash().alias('value_hash'),
|
||||||
|
])
|
||||||
|
common = before_latest.join(
|
||||||
|
after_latest,
|
||||||
|
on='time',
|
||||||
|
how='inner',
|
||||||
|
suffix='_after',
|
||||||
|
)
|
||||||
|
changed_times = sorted(
|
||||||
|
common
|
||||||
|
.filter(
|
||||||
|
pl.col('value_hash')
|
||||||
|
!=
|
||||||
|
pl.col('value_hash_after')
|
||||||
|
)
|
||||||
|
['time']
|
||||||
|
.to_list()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
limitations.append('common_value_comparison_unavailable')
|
||||||
|
|
||||||
|
before_gaps: set[tuple] = {
|
||||||
|
(
|
||||||
|
gap['left_timestamp'],
|
||||||
|
gap['right_timestamp'],
|
||||||
|
)
|
||||||
|
for gap in before['gaps']['intervals']
|
||||||
|
}
|
||||||
|
after_gaps: set[tuple] = {
|
||||||
|
(
|
||||||
|
gap['left_timestamp'],
|
||||||
|
gap['right_timestamp'],
|
||||||
|
)
|
||||||
|
for gap in after['gaps']['intervals']
|
||||||
|
}
|
||||||
|
before_min = min(before_times) if before_times else None
|
||||||
|
after_min = min(after_times) if after_times else None
|
||||||
|
before_max = max(before_times) if before_times else None
|
||||||
|
after_max = max(after_times) if after_times else None
|
||||||
|
checks: dict[str, bool] = {
|
||||||
|
'after_structural_ok': after['result']['structural_ok'],
|
||||||
|
'no_timestamps_lost': bool(
|
||||||
|
before_times is not None
|
||||||
|
and
|
||||||
|
after_times is not None
|
||||||
|
and
|
||||||
|
not missing_times
|
||||||
|
),
|
||||||
|
'minimum_not_later': bool(
|
||||||
|
before_min is not None
|
||||||
|
and
|
||||||
|
after_min is not None
|
||||||
|
and
|
||||||
|
after_min
|
||||||
|
<=
|
||||||
|
before_min
|
||||||
|
),
|
||||||
|
'maximum_not_earlier': bool(
|
||||||
|
before_max is not None
|
||||||
|
and
|
||||||
|
after_max is not None
|
||||||
|
and
|
||||||
|
after_max
|
||||||
|
>=
|
||||||
|
before_max
|
||||||
|
),
|
||||||
|
'timestamps_unique': (
|
||||||
|
after['timestamps'].get('duplicate_excess') == 0
|
||||||
|
),
|
||||||
|
'timestamps_ordered': (
|
||||||
|
after['timestamps'].get('non_positive_delta') == 0
|
||||||
|
),
|
||||||
|
'index_canonical': after['index']['canonical_from_zero'],
|
||||||
|
'ohlcv_finite': after['values']['all_finite'],
|
||||||
|
}
|
||||||
|
if require_newer:
|
||||||
|
checks['maximum_advanced'] = bool(
|
||||||
|
before_max is not None
|
||||||
|
and
|
||||||
|
after_max is not None
|
||||||
|
and
|
||||||
|
after_max > before_max
|
||||||
|
)
|
||||||
|
|
||||||
|
gap_details_truncated: bool = bool(
|
||||||
|
before['gaps']['details_truncated']
|
||||||
|
or
|
||||||
|
after['gaps']['details_truncated']
|
||||||
|
)
|
||||||
|
gaps_added: list|None = None
|
||||||
|
gaps_removed: list|None = None
|
||||||
|
if not gap_details_truncated:
|
||||||
|
gaps_added = sorted(after_gaps - before_gaps)
|
||||||
|
gaps_removed = sorted(before_gaps - after_gaps)
|
||||||
|
comparison: dict = {
|
||||||
|
'before_phase': before_phase,
|
||||||
|
'after_phase': after_phase,
|
||||||
|
'checks': checks,
|
||||||
|
'passed': all(checks.values()),
|
||||||
|
'before_rows': before['rows']['count'],
|
||||||
|
'after_rows': after['rows']['count'],
|
||||||
|
'missing_timestamp_count': len(missing_times),
|
||||||
|
'missing_timestamps': missing_times[:200],
|
||||||
|
'missing_timestamps_truncated': len(missing_times) > 200,
|
||||||
|
'before_duplicate_excess': (
|
||||||
|
before['timestamps'].get('duplicate_excess')
|
||||||
|
),
|
||||||
|
'after_duplicate_excess': (
|
||||||
|
after['timestamps'].get('duplicate_excess')
|
||||||
|
),
|
||||||
|
'changed_common_row_count': (
|
||||||
|
len(changed_times)
|
||||||
|
if changed_times is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
'changed_common_timestamps': (
|
||||||
|
changed_times[:200]
|
||||||
|
if changed_times is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
'changed_common_rows_truncated': bool(
|
||||||
|
changed_times is not None
|
||||||
|
and
|
||||||
|
len(changed_times) > 200
|
||||||
|
),
|
||||||
|
'gaps_added': gaps_added,
|
||||||
|
'gaps_removed': gaps_removed,
|
||||||
|
'gap_comparison_truncated': gap_details_truncated,
|
||||||
|
'limitations': limitations,
|
||||||
|
}
|
||||||
|
output: Path = (
|
||||||
|
evidence
|
||||||
|
/f'compare-{before_phase}-to-{after_phase}.json'
|
||||||
|
)
|
||||||
|
if output.is_symlink():
|
||||||
|
raise RuntimeError(f'Refusing symlinked output: {output}')
|
||||||
|
if output.exists():
|
||||||
|
raise FileExistsError(output)
|
||||||
|
with output.open('x') as output_file:
|
||||||
|
os.fchmod(output_file.fileno(), 0o444)
|
||||||
|
output_file.write(
|
||||||
|
json.dumps(
|
||||||
|
comparison,
|
||||||
|
allow_nan=False,
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f'Comparison saved: {output}')
|
||||||
|
for check, passed in checks.items():
|
||||||
|
print(f' {"PASS" if passed else "FAIL"}: {check}')
|
||||||
|
print(
|
||||||
|
f' changed common rows: '
|
||||||
|
f'{None if changed_times is None else len(changed_times)}\n'
|
||||||
|
f' gaps added: '
|
||||||
|
f'{None if gaps_added is None else len(gaps_added)}\n'
|
||||||
|
f' gaps removed: '
|
||||||
|
f'{None if gaps_removed is None else len(gaps_removed)}\n'
|
||||||
|
)
|
||||||
|
if not comparison['passed']:
|
||||||
|
raise AssertionError(
|
||||||
|
f'Qualification comparison failed: {output}'
|
||||||
|
)
|
||||||
|
return comparison
|
||||||
|
|
@ -0,0 +1,564 @@
|
||||||
|
'''
|
||||||
|
Operator backfill-audit helper regressions.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from piker.storage._audit import audit_ohlcv_parquet
|
||||||
|
|
||||||
|
|
||||||
|
def load_helpers() -> dict:
|
||||||
|
'''
|
||||||
|
Load the sourceable xonsh file as its pure-Python namespace.
|
||||||
|
|
||||||
|
'''
|
||||||
|
path = (
|
||||||
|
Path(__file__).parents[1]
|
||||||
|
/'snippets'
|
||||||
|
/'nativedb_backfill_audit.xsh'
|
||||||
|
)
|
||||||
|
return runpy.run_path(path)
|
||||||
|
|
||||||
|
|
||||||
|
def mk_frame(
|
||||||
|
times: tuple[int, ...],
|
||||||
|
closes: tuple[float, ...]|None = None,
|
||||||
|
) -> pl.DataFrame:
|
||||||
|
'''
|
||||||
|
Build canonical history for evidence comparisons.
|
||||||
|
|
||||||
|
'''
|
||||||
|
size: int = len(times)
|
||||||
|
closes = closes or tuple(float(value) for value in times)
|
||||||
|
return pl.DataFrame({
|
||||||
|
'index': pl.Series(np.arange(size), dtype=pl.Int64),
|
||||||
|
'time': pl.Series(times, dtype=pl.Int64),
|
||||||
|
'open': pl.Series(np.arange(size), dtype=pl.Float64),
|
||||||
|
'high': pl.Series(np.arange(size) + 1, dtype=pl.Float64),
|
||||||
|
'low': pl.Series(np.arange(size), dtype=pl.Float64),
|
||||||
|
'close': pl.Series(closes, dtype=pl.Float64),
|
||||||
|
'volume': pl.Series(np.arange(size), dtype=pl.Float64),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def init_case(
|
||||||
|
helpers: dict,
|
||||||
|
run_root: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Initialize one fixed synthetic qualification case.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
helpers['bfq_record_case'](
|
||||||
|
run_root,
|
||||||
|
fqme='x.test',
|
||||||
|
period_s=60,
|
||||||
|
provider='fake',
|
||||||
|
symptom='synthetic lifecycle regression',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_phase_evidence(
|
||||||
|
run_root: Path,
|
||||||
|
phase: str,
|
||||||
|
path: Path,
|
||||||
|
fqme: str = 'x.test',
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Write checksum-bound report and manual metadata for a snapshot.
|
||||||
|
|
||||||
|
'''
|
||||||
|
report: dict = audit_ohlcv_parquet(
|
||||||
|
path,
|
||||||
|
fqme=fqme,
|
||||||
|
period_s=60,
|
||||||
|
)
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
report_path: Path = evidence / f'{phase}.json'
|
||||||
|
report_path.write_text(
|
||||||
|
json.dumps(report, indent=2, sort_keys=True) + '\n'
|
||||||
|
)
|
||||||
|
manual: dict = {
|
||||||
|
'phase': phase,
|
||||||
|
'run_root': str(run_root.resolve()),
|
||||||
|
'snapshot_path': path.name,
|
||||||
|
'report_sha256': sha256(
|
||||||
|
report_path.read_bytes()
|
||||||
|
).hexdigest(),
|
||||||
|
}
|
||||||
|
(evidence / f'{phase}.manual.json').write_text(
|
||||||
|
json.dumps(manual, indent=2, sort_keys=True) + '\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disposable_seed_is_archived_before_fresh(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Fresh qualification must never remove the operator's source file.
|
||||||
|
|
||||||
|
The workflow starts from known-bad production evidence but needs
|
||||||
|
an empty disposable NativeDB for a full provider backfill.
|
||||||
|
Initialize a marked root, seed by copy, then clear for fresh and
|
||||||
|
prove both the original and evidence copy survive while only the
|
||||||
|
disposable active path is moved into the evidence directory.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
source: Path = tmp_path / 'known-bad.parquet'
|
||||||
|
mk_frame((60, 120)).write_parquet(source)
|
||||||
|
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
active: Path = helpers['bfq_seed'](
|
||||||
|
run_root,
|
||||||
|
'x.test',
|
||||||
|
60,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
archived: Path = helpers['bfq_clear_for_fresh'](
|
||||||
|
run_root,
|
||||||
|
'x.test',
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source.is_file()
|
||||||
|
assert (run_root / 'evidence' / 'seed.parquet').is_file()
|
||||||
|
assert archived.is_file()
|
||||||
|
assert not active.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_active_copy_comes_from_captured_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Source replacement must not split baseline and replay bytes.
|
||||||
|
|
||||||
|
Production writers replace Parquet atomically, so copying the
|
||||||
|
source twice can capture one generation as evidence and replay
|
||||||
|
another. Replace the source after the first copy, then prove the
|
||||||
|
the active disposable file still derives
|
||||||
|
from the captured seed rather than reopening the changed source.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
source: Path = tmp_path / 'source.parquet'
|
||||||
|
original: bytes
|
||||||
|
mk_frame((60, 120)).write_parquet(source)
|
||||||
|
original = source.read_bytes()
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
real_copy = helpers['_bfq_copy_exclusive']
|
||||||
|
copied: int = 0
|
||||||
|
|
||||||
|
def replace_after_capture(
|
||||||
|
src: Path,
|
||||||
|
dst: Path,
|
||||||
|
mode: int|None = None,
|
||||||
|
) -> None:
|
||||||
|
nonlocal copied
|
||||||
|
real_copy(src, dst, mode=mode)
|
||||||
|
copied += 1
|
||||||
|
if copied == 1:
|
||||||
|
mk_frame((60, 180)).write_parquet(source)
|
||||||
|
|
||||||
|
helpers['bfq_seed'].__globals__[
|
||||||
|
'_bfq_copy_exclusive'
|
||||||
|
] = replace_after_capture
|
||||||
|
active: Path = helpers['bfq_seed'](
|
||||||
|
run_root,
|
||||||
|
'x.test',
|
||||||
|
60,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source.read_bytes() != original
|
||||||
|
assert active.read_bytes() == original
|
||||||
|
assert (
|
||||||
|
run_root / 'evidence' / 'seed.parquet'
|
||||||
|
).read_bytes() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_copy_failure_removes_partial_active_file(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Failed replay copy must not leave an active partial Parquet.
|
||||||
|
|
||||||
|
Seed evidence can complete before the disposable active copy runs
|
||||||
|
out of space. A partial active path blocks retry and lets a chart
|
||||||
|
mistake truncated bytes for history. Fail the second copy
|
||||||
|
after a short write and prove active storage is removed while the
|
||||||
|
completed seed remains exact.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
source: Path = tmp_path / 'source.parquet'
|
||||||
|
mk_frame((60, 120)).write_parquet(source)
|
||||||
|
before: bytes = source.read_bytes()
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
real_copy = helpers['shutil'].copyfileobj
|
||||||
|
copies: int = 0
|
||||||
|
|
||||||
|
def fail_second(source_file, destination_file) -> None:
|
||||||
|
nonlocal copies
|
||||||
|
copies += 1
|
||||||
|
if copies == 2:
|
||||||
|
destination_file.write(source_file.read(10))
|
||||||
|
raise OSError('simulated full filesystem')
|
||||||
|
real_copy(source_file, destination_file)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
helpers['shutil'],
|
||||||
|
'copyfileobj',
|
||||||
|
fail_second,
|
||||||
|
)
|
||||||
|
with pytest.raises(OSError, match='full filesystem'):
|
||||||
|
helpers['bfq_seed'](
|
||||||
|
run_root,
|
||||||
|
'x.test',
|
||||||
|
60,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
active: Path = (
|
||||||
|
run_root
|
||||||
|
/'xdg/piker/nativedb/x.test.ohlcv60s.parquet'
|
||||||
|
)
|
||||||
|
assert not active.exists()
|
||||||
|
assert (
|
||||||
|
run_root / 'evidence' / 'seed.parquet'
|
||||||
|
).read_bytes() == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers_reject_paths_outside_marked_root(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A marker must not authorize traversal or symlinked paths.
|
||||||
|
|
||||||
|
FQME text becomes part of the active Parquet filename and fresh
|
||||||
|
reset moves that path. Prove parent traversal is rejected, then
|
||||||
|
replace the evidence directory with a symlink and prove path
|
||||||
|
construction refuses to cross the disposable-root boundary.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='Unsafe FQME'):
|
||||||
|
helpers['_bfq_paths'](
|
||||||
|
run_root,
|
||||||
|
'../../outside',
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
outside: Path = tmp_path / 'outside'
|
||||||
|
outside.mkdir()
|
||||||
|
evidence.rmdir()
|
||||||
|
evidence.symlink_to(outside, target_is_directory=True)
|
||||||
|
with pytest.raises(RuntimeError, match='symlinked path'):
|
||||||
|
helpers['bfq_init'](
|
||||||
|
run_root,
|
||||||
|
validate_piker=False,
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match='escaped run root'):
|
||||||
|
helpers['_bfq_paths'](run_root, 'x.test', 60)
|
||||||
|
with pytest.raises(RuntimeError, match='Unsafe evidence'):
|
||||||
|
helpers['bfq_compare'](run_root, 'before', 'after')
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers_reject_dangling_evidence_symlinks(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Final evidence names must not follow dangling external symlinks.
|
||||||
|
|
||||||
|
``Path.exists()`` is false for a dangling symlink, which can make
|
||||||
|
a check-then-write path create its external target. Link the case
|
||||||
|
record and seed outside a marked root. Prove both writers reject
|
||||||
|
the final component before creating external bytes.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
helpers['bfq_init'](run_root, validate_piker=False)
|
||||||
|
outside_case: Path = tmp_path / 'outside-case.json'
|
||||||
|
case_path: Path = run_root / 'case.json'
|
||||||
|
case_path.symlink_to(outside_case)
|
||||||
|
with pytest.raises(RuntimeError, match='symlinked case'):
|
||||||
|
helpers['bfq_record_case'](
|
||||||
|
run_root,
|
||||||
|
fqme='x.test',
|
||||||
|
period_s=60,
|
||||||
|
provider='fake',
|
||||||
|
symptom='unsafe path test',
|
||||||
|
)
|
||||||
|
assert not outside_case.exists()
|
||||||
|
|
||||||
|
case_path.unlink()
|
||||||
|
source: Path = tmp_path / 'source.parquet'
|
||||||
|
mk_frame((60, 120)).write_parquet(source)
|
||||||
|
outside_seed: Path = tmp_path / 'outside-seed.parquet'
|
||||||
|
seed: Path = run_root / 'evidence' / 'seed.parquet'
|
||||||
|
seed.symlink_to(outside_seed)
|
||||||
|
with pytest.raises(RuntimeError, match='symlinked output'):
|
||||||
|
helpers['bfq_seed'](
|
||||||
|
run_root,
|
||||||
|
'x.test',
|
||||||
|
60,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
assert not outside_seed.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bfq_use_updates_xonsh_subprocess_environment(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Disposable XDG selection must reach commands launched by xonsh.
|
||||||
|
|
||||||
|
Updating only ``os.environ`` does not mutate xonsh's environment
|
||||||
|
mapping, so manual chart commands previously retained normal user
|
||||||
|
storage. Source the real snippet in xonsh, initialize a run root,
|
||||||
|
and assert a subprocess sees the disposable XDG path.
|
||||||
|
|
||||||
|
'''
|
||||||
|
repo: Path = Path(__file__).parents[1]
|
||||||
|
snippet: Path = repo / 'snippets' / 'nativedb_backfill_audit.xsh'
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
code: str = (
|
||||||
|
f'source {snippet}; '
|
||||||
|
f'bfq_init(p\'{run_root}\', validate_piker=False); '
|
||||||
|
'env'
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
['xonsh', '-c', code],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
cwd=repo,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
expected: str = f'XDG_CONFIG_HOME={run_root / "xdg"}'
|
||||||
|
assert result.stdout.count(expected) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_validates_console_script_against_worktree(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
An installed console script must import the reviewed worktree.
|
||||||
|
|
||||||
|
A shared virtualenv's ``piker`` entry point can resolve its
|
||||||
|
editable root checkout, where a new audit command is absent, even
|
||||||
|
while the operator is in this linked worktree. Run initialization
|
||||||
|
and prove its PYTHONPATH-bound preflight sees the local command
|
||||||
|
before it records the executable and repository identity.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
|
||||||
|
helpers['bfq_init'](run_root)
|
||||||
|
|
||||||
|
marker: dict = json.loads(
|
||||||
|
(run_root / '.piker-backfill-qualification').read_text()
|
||||||
|
)
|
||||||
|
assert Path(marker['repo_root']) == Path(__file__).parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase_compare_detects_preservation_and_seam_changes(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Lifecycle comparison must preserve data without hiding seams.
|
||||||
|
|
||||||
|
Restart and append may replace a matching provider seam while old
|
||||||
|
timestamps remain. Save two read-only snapshots where the second
|
||||||
|
appends a row and updates a close. Prove checks pass while the
|
||||||
|
the changed timestamp stays explicit operator evidence.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
init_case(helpers, run_root)
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
before_path: Path = evidence / 'fresh.parquet'
|
||||||
|
after_path: Path = evidence / 'restart.parquet'
|
||||||
|
mk_frame((60, 120), (1, 2)).write_parquet(before_path)
|
||||||
|
mk_frame((60, 120, 180), (1, 20, 3)).write_parquet(after_path)
|
||||||
|
|
||||||
|
for phase, path in (
|
||||||
|
('fresh', before_path),
|
||||||
|
('restart', after_path),
|
||||||
|
):
|
||||||
|
write_phase_evidence(run_root, phase, path)
|
||||||
|
|
||||||
|
comparison: dict = helpers['bfq_compare'](
|
||||||
|
run_root,
|
||||||
|
'fresh',
|
||||||
|
'restart',
|
||||||
|
require_newer=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comparison['passed'] is True
|
||||||
|
assert comparison['missing_timestamp_count'] == 0
|
||||||
|
assert comparison['changed_common_row_count'] == 1
|
||||||
|
assert comparison['changed_common_timestamps'] == [120]
|
||||||
|
assert comparison['checks']['maximum_advanced'] is True
|
||||||
|
with pytest.raises(ValueError, match='phases must differ'):
|
||||||
|
helpers['bfq_compare'](run_root, 'fresh', 'fresh')
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_compare_allows_invalid_and_duplicate_removal(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Repair may remove invalid rows but must preserve valid times.
|
||||||
|
|
||||||
|
Known-bad history may contain zero-epoch and duplicate rows. Both
|
||||||
|
should disappear during canonical rewrite. Compare
|
||||||
|
that baseline to clean extended history and prove preservation
|
||||||
|
ignores invalid epoch and duplicate multiplicity while recording
|
||||||
|
their before and after counts.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
init_case(helpers, run_root)
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
before_path: Path = evidence / 'baseline.parquet'
|
||||||
|
after_path: Path = evidence / 'repair.parquet'
|
||||||
|
mk_frame((0, 60, 60, 120)).write_parquet(before_path)
|
||||||
|
mk_frame((60, 120, 180)).write_parquet(after_path)
|
||||||
|
|
||||||
|
for phase, path in (
|
||||||
|
('baseline', before_path),
|
||||||
|
('repair', after_path),
|
||||||
|
):
|
||||||
|
write_phase_evidence(run_root, phase, path)
|
||||||
|
|
||||||
|
comparison: dict = helpers['bfq_compare'](
|
||||||
|
run_root,
|
||||||
|
'baseline',
|
||||||
|
'repair',
|
||||||
|
require_newer=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert comparison['passed'] is True
|
||||||
|
assert comparison['missing_timestamp_count'] == 0
|
||||||
|
assert comparison['before_duplicate_excess'] == 1
|
||||||
|
assert comparison['after_duplicate_excess'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_baseline_produces_failed_comparison_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Comparison must report unauditable baselines instead of crashing.
|
||||||
|
|
||||||
|
Raw audit accepts malformed Parquet so humans can save evidence
|
||||||
|
before repair. Write a baseline with textual timestamps and no
|
||||||
|
close column, compare it with canonical history, and prove the
|
||||||
|
helper writes limitations plus failed checks before raising its
|
||||||
|
qualification assertion.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
init_case(helpers, run_root)
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
before_path: Path = evidence / 'broken.parquet'
|
||||||
|
after_path: Path = evidence / 'repair.parquet'
|
||||||
|
mk_frame((60, 120)).drop('close').with_columns(
|
||||||
|
pl.Series('time', ['60', '120'])
|
||||||
|
).write_parquet(before_path)
|
||||||
|
mk_frame((60, 120, 180)).write_parquet(after_path)
|
||||||
|
|
||||||
|
for phase, path in (
|
||||||
|
('broken', before_path),
|
||||||
|
('repair', after_path),
|
||||||
|
):
|
||||||
|
write_phase_evidence(run_root, phase, path)
|
||||||
|
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
helpers['bfq_compare'](
|
||||||
|
run_root,
|
||||||
|
'broken',
|
||||||
|
'repair',
|
||||||
|
)
|
||||||
|
|
||||||
|
comparison: dict = json.loads(
|
||||||
|
(
|
||||||
|
evidence
|
||||||
|
/'compare-broken-to-repair.json'
|
||||||
|
).read_text()
|
||||||
|
)
|
||||||
|
assert comparison['passed'] is False
|
||||||
|
assert comparison['checks']['no_timestamps_lost'] is False
|
||||||
|
assert 'before_time_nonnumeric' in comparison['limitations']
|
||||||
|
assert (
|
||||||
|
'common_value_comparison_unavailable'
|
||||||
|
in comparison['limitations']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_rejects_modified_or_cross_case_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Lifecycle checks must not combine unrelated or altered artifacts.
|
||||||
|
|
||||||
|
JSON can remain plausible when a phase snapshot is replaced or a
|
||||||
|
report was generated for another market. Bind each report to its
|
||||||
|
checksum and fixed case metadata. Prove both changes abort before
|
||||||
|
abort before a comparison result can be accepted.
|
||||||
|
|
||||||
|
'''
|
||||||
|
helpers: dict = load_helpers()
|
||||||
|
run_root: Path = tmp_path / 'run'
|
||||||
|
init_case(helpers, run_root)
|
||||||
|
evidence: Path = run_root / 'evidence'
|
||||||
|
before: Path = evidence / 'before.parquet'
|
||||||
|
after: Path = evidence / 'after.parquet'
|
||||||
|
mk_frame((60, 120)).write_parquet(before)
|
||||||
|
mk_frame((60, 120, 180)).write_parquet(after)
|
||||||
|
write_phase_evidence(run_root, 'before', before)
|
||||||
|
write_phase_evidence(run_root, 'after', after)
|
||||||
|
|
||||||
|
after_report: Path = evidence / 'after.json'
|
||||||
|
report_bytes: bytes = after_report.read_bytes()
|
||||||
|
after_report.write_bytes(report_bytes + b'\n')
|
||||||
|
with pytest.raises(RuntimeError, match='report checksum'):
|
||||||
|
helpers['bfq_compare'](run_root, 'before', 'after')
|
||||||
|
after_report.write_bytes(report_bytes)
|
||||||
|
|
||||||
|
mk_frame((60, 180)).write_parquet(before)
|
||||||
|
with pytest.raises(RuntimeError, match='snapshot checksum'):
|
||||||
|
helpers['bfq_compare'](run_root, 'before', 'after')
|
||||||
|
|
||||||
|
other: Path = evidence / 'other.parquet'
|
||||||
|
mk_frame((60, 120)).write_parquet(other)
|
||||||
|
write_phase_evidence(
|
||||||
|
run_root,
|
||||||
|
'other',
|
||||||
|
other,
|
||||||
|
fqme='other.test',
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match='does not match'):
|
||||||
|
helpers['bfq_compare'](run_root, 'other', 'after')
|
||||||
Loading…
Reference in New Issue