qavf 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qavf/__init__.py +128 -0
- qavf/_audit_temporal.py +43 -0
- qavf/_float_accumulator.py +52 -0
- qavf/_observations.py +71 -0
- qavf/_version.py +3 -0
- qavf/aggregation.py +84 -0
- qavf/api.py +34 -0
- qavf/audit_codec.py +428 -0
- qavf/cli.py +429 -0
- qavf/demo.py +236 -0
- qavf/demo_config.yaml +155 -0
- qavf/docgen.py +191 -0
- qavf/engine/__init__.py +1 -0
- qavf/engine/cache_ownership.py +372 -0
- qavf/engine/capabilities.py +27 -0
- qavf/engine/catalog.py +285 -0
- qavf/engine/compilation.py +216 -0
- qavf/engine/connections.py +199 -0
- qavf/engine/context.py +1322 -0
- qavf/engine/observation.py +369 -0
- qavf/engine/observation_plan.py +204 -0
- qavf/engine/owned_ipc.py +31 -0
- qavf/engine/planning.py +135 -0
- qavf/engine/projection.py +76 -0
- qavf/engine/registry.py +493 -0
- qavf/engine/relations.py +27 -0
- qavf/engine/resources.py +425 -0
- qavf/engine/runner.py +912 -0
- qavf/engine/session.py +133 -0
- qavf/engine/severity.py +80 -0
- qavf/engine/source_read.py +148 -0
- qavf/engine/spill.py +129 -0
- qavf/engine/sql.py +172 -0
- qavf/engine/sql_read.py +227 -0
- qavf/engine/sqlite_store.py +128 -0
- qavf/engine/thread_budget.py +23 -0
- qavf/execution/__init__.py +0 -0
- qavf/execution/_windows_job.py +279 -0
- qavf/execution/bootstrap.py +61 -0
- qavf/execution/dispatch.py +46 -0
- qavf/execution/errors.py +11 -0
- qavf/execution/inputs.py +33 -0
- qavf/execution/os_limits.py +87 -0
- qavf/execution/policy.py +8 -0
- qavf/execution/protocol.py +86 -0
- qavf/execution/supervisor.py +434 -0
- qavf/execution/worker.py +99 -0
- qavf/inputs/__init__.py +4 -0
- qavf/inputs/adapters.py +136 -0
- qavf/inputs/descriptors.py +201 -0
- qavf/inputs/normalize.py +285 -0
- qavf/inputs/resources.py +120 -0
- qavf/io.py +109 -0
- qavf/key_codec.py +35 -0
- qavf/keys.py +112 -0
- qavf/numeric.py +150 -0
- qavf/ordering.py +59 -0
- qavf/output_format.py +35 -0
- qavf/profiling/__init__.py +108 -0
- qavf/profiling/accumulator.py +133 -0
- qavf/profiling/values.py +134 -0
- qavf/py.typed +0 -0
- qavf/redaction.py +221 -0
- qavf/regression/__init__.py +28 -0
- qavf/regression/_baseline_writer.py +176 -0
- qavf/regression/_cells.py +125 -0
- qavf/regression/_columns.py +10 -0
- qavf/regression/_comparison.py +270 -0
- qavf/regression/_evidence.py +167 -0
- qavf/regression/_indexed.py +205 -0
- qavf/regression/_inputs.py +146 -0
- qavf/regression/_numeric.py +96 -0
- qavf/regression/_plan.py +208 -0
- qavf/regression/_schema.py +77 -0
- qavf/regression/_streaming.py +221 -0
- qavf/regression/baselines.py +357 -0
- qavf/regression/compare_outputs.py +282 -0
- qavf/regression/models.py +163 -0
- qavf/reports/__init__.py +1 -0
- qavf/reports/charts.py +101 -0
- qavf/reports/html.py +287 -0
- qavf/reports/models.py +508 -0
- qavf/reports/presentation.py +117 -0
- qavf/reports/templates/records.html.j2 +12 -0
- qavf/reports/templates/report.html.j2 +288 -0
- qavf/rules/__init__.py +1 -0
- qavf/rules/_column_batch.py +235 -0
- qavf/rules/_column_plan.py +543 -0
- qavf/rules/_column_rows.py +131 -0
- qavf/rules/_column_stream.py +110 -0
- qavf/rules/_contracts.py +58 -0
- qavf/rules/_coordinate_sql.py +98 -0
- qavf/rules/_coverage.py +247 -0
- qavf/rules/_distribution.py +170 -0
- qavf/rules/_domains.py +77 -0
- qavf/rules/_exact_grouped.py +196 -0
- qavf/rules/_execution.py +334 -0
- qavf/rules/_grid.py +210 -0
- qavf/rules/_grouped.py +338 -0
- qavf/rules/_key_summary.py +131 -0
- qavf/rules/_ordered_grouped.py +107 -0
- qavf/rules/_outlier_batches.py +298 -0
- qavf/rules/_predicates.py +378 -0
- qavf/rules/_regex.py +32 -0
- qavf/rules/_row_summary.py +97 -0
- qavf/rules/_spatial_aggregation.py +120 -0
- qavf/rules/_statistics_plan.py +69 -0
- qavf/rules/_temporal.py +82 -0
- qavf/rules/freshness_checks.py +205 -0
- qavf/rules/geo_checks.py +244 -0
- qavf/rules/helpers.py +468 -0
- qavf/rules/input_checks/__init__.py +23 -0
- qavf/rules/input_checks/_column_eager.py +347 -0
- qavf/rules/input_checks/_column_simple.py +326 -0
- qavf/rules/input_checks/_column_values.py +192 -0
- qavf/rules/input_checks/_datasets.py +107 -0
- qavf/rules/input_checks/availability.py +192 -0
- qavf/rules/input_checks/columns.py +160 -0
- qavf/rules/input_checks/expressions.py +184 -0
- qavf/rules/input_checks/keys.py +260 -0
- qavf/rules/input_checks/references.py +432 -0
- qavf/rules/reconciliation_checks.py +1020 -0
- qavf/rules/regression_checks.py +250 -0
- qavf/rules/spatial_checks.py +881 -0
- qavf/rules/statistical_checks.py +411 -0
- qavf/rules/temporal_checks.py +1654 -0
- qavf/scaffold.py +241 -0
- qavf/settings.py +1046 -0
- qavf/sources/__init__.py +41 -0
- qavf/sources/base.py +424 -0
- qavf/sources/builtin.py +17 -0
- qavf/sources/collection.py +18 -0
- qavf/sources/errors.py +61 -0
- qavf/sources/execution.py +104 -0
- qavf/sources/file.py +483 -0
- qavf/sources/formats.py +111 -0
- qavf/sources/frames.py +44 -0
- qavf/sources/identity.py +89 -0
- qavf/sources/memory.py +48 -0
- qavf/sources/native.py +165 -0
- qavf/sources/parameters.py +152 -0
- qavf/sources/python.py +52 -0
- qavf/sources/query.py +108 -0
- qavf/sources/sql.py +452 -0
- qavf/sources/typed.py +40 -0
- qavf/statistics/__init__.py +1 -0
- qavf/statistics/ordered.py +189 -0
- qavf/stats.py +600 -0
- qavf/testing.py +115 -0
- qavf/typed_values.py +64 -0
- qavf-0.4.0.dist-info/METADATA +257 -0
- qavf-0.4.0.dist-info/RECORD +155 -0
- qavf-0.4.0.dist-info/WHEEL +4 -0
- qavf-0.4.0.dist-info/entry_points.txt +2 -0
- qavf-0.4.0.dist-info/licenses/LICENSE +21 -0
qavf/__init__.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""QAVF: explicit QA contracts for files, frames and queries.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from qavf import validate
|
|
6
|
+
|
|
7
|
+
report = validate("counts.parquet", schema={"columns": {"count": {"ge": 0}}})
|
|
8
|
+
report.write_json("qa_report.json")
|
|
9
|
+
|
|
10
|
+
Public objects are imported when requested, so importing QAVF's version does
|
|
11
|
+
not initialize dataframe, regression or report-rendering dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib import import_module
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from qavf._version import __version__
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from qavf.api import validate
|
|
23
|
+
from qavf.engine.compilation import ConfigurationError, build_registry
|
|
24
|
+
from qavf.engine.context import DataContext
|
|
25
|
+
from qavf.engine.planning import DataRequirements
|
|
26
|
+
from qavf.engine.registry import Finding, Rule, RuleParams, RuleRegistry, register
|
|
27
|
+
from qavf.engine.runner import QARunner
|
|
28
|
+
from qavf.engine.severity import Severity, Status
|
|
29
|
+
from qavf.execution.errors import ExecutionLimitError
|
|
30
|
+
from qavf.inputs.descriptors import Dataset, File, Loader, Query
|
|
31
|
+
from qavf.inputs.resources import ResourceLimits
|
|
32
|
+
from qavf.profiling import ProfileResult, profile_input
|
|
33
|
+
from qavf.regression.compare_outputs import (
|
|
34
|
+
assert_outputs_match,
|
|
35
|
+
compare_frames,
|
|
36
|
+
compare_outputs,
|
|
37
|
+
)
|
|
38
|
+
from qavf.regression.models import ComparisonResult
|
|
39
|
+
from qavf.reports.html import render_html, write_html
|
|
40
|
+
from qavf.reports.models import QAReport, RuleResult
|
|
41
|
+
from qavf.settings import QAVFSettings, load_settings
|
|
42
|
+
from qavf.sources import DataSource, SourceParams
|
|
43
|
+
|
|
44
|
+
_EXPORTS = {
|
|
45
|
+
"ComparisonResult": "qavf.regression.models",
|
|
46
|
+
"ConfigurationError": "qavf.engine.compilation",
|
|
47
|
+
"DataContext": "qavf.engine.context",
|
|
48
|
+
"DataRequirements": "qavf.engine.planning",
|
|
49
|
+
"DataSource": "qavf.sources",
|
|
50
|
+
"Dataset": "qavf.inputs.descriptors",
|
|
51
|
+
"ExecutionLimitError": "qavf.execution.errors",
|
|
52
|
+
"File": "qavf.inputs.descriptors",
|
|
53
|
+
"Finding": "qavf.engine.registry",
|
|
54
|
+
"Loader": "qavf.inputs.descriptors",
|
|
55
|
+
"ProfileResult": "qavf.profiling",
|
|
56
|
+
"QAReport": "qavf.reports.models",
|
|
57
|
+
"QARunner": "qavf.engine.runner",
|
|
58
|
+
"QAVFSettings": "qavf.settings",
|
|
59
|
+
"Query": "qavf.inputs.descriptors",
|
|
60
|
+
"ResourceLimits": "qavf.inputs.resources",
|
|
61
|
+
"Rule": "qavf.engine.registry",
|
|
62
|
+
"RuleParams": "qavf.engine.registry",
|
|
63
|
+
"RuleRegistry": "qavf.engine.registry",
|
|
64
|
+
"RuleResult": "qavf.reports.models",
|
|
65
|
+
"Severity": "qavf.engine.severity",
|
|
66
|
+
"SourceParams": "qavf.sources",
|
|
67
|
+
"Status": "qavf.engine.severity",
|
|
68
|
+
"assert_outputs_match": "qavf.regression.compare_outputs",
|
|
69
|
+
"build_registry": "qavf.engine.compilation",
|
|
70
|
+
"compare_frames": "qavf.regression.compare_outputs",
|
|
71
|
+
"compare_outputs": "qavf.regression.compare_outputs",
|
|
72
|
+
"load_settings": "qavf.settings",
|
|
73
|
+
"profile_input": "qavf.profiling",
|
|
74
|
+
"register": "qavf.engine.registry",
|
|
75
|
+
"render_html": "qavf.reports.html",
|
|
76
|
+
"validate": "qavf.api",
|
|
77
|
+
"write_html": "qavf.reports.html",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"ComparisonResult",
|
|
82
|
+
"ConfigurationError",
|
|
83
|
+
"DataContext",
|
|
84
|
+
"DataRequirements",
|
|
85
|
+
"DataSource",
|
|
86
|
+
"Dataset",
|
|
87
|
+
"ExecutionLimitError",
|
|
88
|
+
"File",
|
|
89
|
+
"Finding",
|
|
90
|
+
"Loader",
|
|
91
|
+
"ProfileResult",
|
|
92
|
+
"QAReport",
|
|
93
|
+
"QARunner",
|
|
94
|
+
"QAVFSettings",
|
|
95
|
+
"Query",
|
|
96
|
+
"ResourceLimits",
|
|
97
|
+
"Rule",
|
|
98
|
+
"RuleParams",
|
|
99
|
+
"RuleRegistry",
|
|
100
|
+
"RuleResult",
|
|
101
|
+
"Severity",
|
|
102
|
+
"SourceParams",
|
|
103
|
+
"Status",
|
|
104
|
+
"__version__",
|
|
105
|
+
"assert_outputs_match",
|
|
106
|
+
"build_registry",
|
|
107
|
+
"compare_frames",
|
|
108
|
+
"compare_outputs",
|
|
109
|
+
"load_settings",
|
|
110
|
+
"profile_input",
|
|
111
|
+
"register",
|
|
112
|
+
"render_html",
|
|
113
|
+
"validate",
|
|
114
|
+
"write_html",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def __getattr__(name: str) -> Any:
|
|
119
|
+
module = _EXPORTS.get(name)
|
|
120
|
+
if module is None:
|
|
121
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
122
|
+
value = getattr(import_module(module), name)
|
|
123
|
+
globals()[name] = value
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def __dir__() -> list[str]:
|
|
128
|
+
return sorted(set(globals()) | set(__all__))
|
qavf/_audit_temporal.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Lossless admission of optional temporal scalar implementations.
|
|
2
|
+
|
|
3
|
+
The audit format has native NumPy temporal tags and Python datetime tags.
|
|
4
|
+
Choose one before subclass checks can silently discard nanoseconds. This
|
|
5
|
+
module never imports pandas, so core validation keeps its optional dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_temporal_scalar(value: Any) -> Any:
|
|
15
|
+
"""Map pandas scalars to existing lossless tags, or reject unsupported values.
|
|
16
|
+
|
|
17
|
+
Naive timestamps and durations retain their native NumPy units. Zoned
|
|
18
|
+
timestamps use Python datetime only when microseconds represent them
|
|
19
|
+
exactly, retaining the zone and DST fold. The existing format cannot
|
|
20
|
+
represent a named zone together with a nanosecond remainder.
|
|
21
|
+
"""
|
|
22
|
+
pandas = sys.modules.get("pandas")
|
|
23
|
+
if pandas is None:
|
|
24
|
+
return value
|
|
25
|
+
# Another thread can be importing the optional adapter. Its module enters
|
|
26
|
+
# sys.modules before these public classes have been assigned.
|
|
27
|
+
if value is getattr(pandas, "NaT", None):
|
|
28
|
+
raise ValueError("audit pandas NaT is ambiguous; use None or a typed NumPy NaT")
|
|
29
|
+
duration_type = getattr(pandas, "Timedelta", None)
|
|
30
|
+
timestamp_type = getattr(pandas, "Timestamp", None)
|
|
31
|
+
scalar: Any = value
|
|
32
|
+
if isinstance(duration_type, type) and isinstance(value, duration_type):
|
|
33
|
+
return scalar.to_timedelta64()
|
|
34
|
+
if isinstance(timestamp_type, type) and isinstance(value, timestamp_type):
|
|
35
|
+
if scalar.tzinfo is None:
|
|
36
|
+
return scalar.to_datetime64()
|
|
37
|
+
if scalar.nanosecond:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"audit pandas Timestamp with both a timezone and nanosecond precision "
|
|
40
|
+
"is unsupported"
|
|
41
|
+
)
|
|
42
|
+
return scalar.to_pydatetime()
|
|
43
|
+
return value
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Mergeable exact accumulation of stored binary floating-point observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from fractions import Fraction
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ExactFloatAccumulator:
|
|
13
|
+
"""Retain an integer total and count, independent of input/chunk ordering.
|
|
14
|
+
|
|
15
|
+
Every Float64 is an integer multiple of 2**-1074. Storage grows only with
|
|
16
|
+
the bit length of the total and count; no observation buffer is retained.
|
|
17
|
+
Null/NaN observations are missing; infinities cannot be aggregated.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
units: int = 0
|
|
21
|
+
count: int = 0
|
|
22
|
+
|
|
23
|
+
def update(self, values: Iterable[float | None]) -> None:
|
|
24
|
+
units, count = self.units, self.count
|
|
25
|
+
for value in values:
|
|
26
|
+
if value is None or math.isnan(value):
|
|
27
|
+
continue
|
|
28
|
+
if not math.isfinite(value):
|
|
29
|
+
raise ValueError("non-finite observations cannot be aggregated")
|
|
30
|
+
numerator, denominator = float(value).as_integer_ratio()
|
|
31
|
+
units += numerator << (1074 - (denominator.bit_length() - 1))
|
|
32
|
+
count += 1
|
|
33
|
+
self.units, self.count = units, count
|
|
34
|
+
|
|
35
|
+
def total(self) -> Fraction | None:
|
|
36
|
+
return Fraction(self.units, 1 << 1074) if self.count else None
|
|
37
|
+
|
|
38
|
+
def update_counted(self, values: Iterable[tuple[float, int]]) -> None:
|
|
39
|
+
"""Accumulate exact value frequencies without expanding repeated observations."""
|
|
40
|
+
units, count = self.units, self.count
|
|
41
|
+
for value, frequency in values:
|
|
42
|
+
if isinstance(frequency, bool) or not isinstance(frequency, int) or frequency < 0:
|
|
43
|
+
raise ValueError("observation frequencies must be nonnegative integers")
|
|
44
|
+
if not math.isfinite(value):
|
|
45
|
+
raise ValueError("non-finite observations cannot be aggregated")
|
|
46
|
+
numerator, denominator = float(value).as_integer_ratio()
|
|
47
|
+
units += (numerator << (1074 - (denominator.bit_length() - 1))) * frequency
|
|
48
|
+
count += frequency
|
|
49
|
+
self.units, self.count = units, count
|
|
50
|
+
|
|
51
|
+
def mean(self) -> Fraction | None:
|
|
52
|
+
return Fraction(self.units, (1 << 1074) * self.count) if self.count else None
|
qavf/_observations.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Lossless admission of caller observations before Float64 statistical kernels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _scalar(value: object) -> float:
|
|
13
|
+
"""Validate the original scalar, before mixed-container dtype inference."""
|
|
14
|
+
if value is None:
|
|
15
|
+
return math.nan
|
|
16
|
+
if isinstance(value, bool | np.bool_):
|
|
17
|
+
raise ValueError("observations must be numeric, not booleans")
|
|
18
|
+
if isinstance(value, float):
|
|
19
|
+
return float(value) # Already stored at Float64 precision, including NaN/inf.
|
|
20
|
+
if not isinstance(value, int | float | np.integer | np.floating | Decimal):
|
|
21
|
+
raise ValueError("observations must be numeric, not coercible text or objects")
|
|
22
|
+
try:
|
|
23
|
+
converted = float(value)
|
|
24
|
+
except (OverflowError, ValueError) as exc:
|
|
25
|
+
raise ValueError("observations cannot be represented exactly as Float64") from exc
|
|
26
|
+
if isinstance(value, int | np.integer):
|
|
27
|
+
exact = math.isfinite(converted) and int(converted) == value
|
|
28
|
+
elif isinstance(value, Decimal):
|
|
29
|
+
exact = not value.is_finite() or (
|
|
30
|
+
math.isfinite(converted) and Decimal.from_float(converted) == value
|
|
31
|
+
)
|
|
32
|
+
else:
|
|
33
|
+
# Preserve the original NumPy precision: converting the source to Python
|
|
34
|
+
# float before comparing would erase the very difference being tested.
|
|
35
|
+
exact = not np.isfinite(value) or (
|
|
36
|
+
math.isfinite(converted) and type(value)(converted) == value
|
|
37
|
+
)
|
|
38
|
+
if not exact:
|
|
39
|
+
raise ValueError("observations cannot be represented exactly as Float64")
|
|
40
|
+
return converted
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def float_observations(values: npt.ArrayLike) -> npt.NDArray[np.float64]:
|
|
44
|
+
"""Return a 1-D Float64 view/copy only after original values prove lossless.
|
|
45
|
+
|
|
46
|
+
Homogeneous NumPy arrays retain vectorized admission. Other array-like inputs
|
|
47
|
+
first retain their original scalar objects, so a mixed float/integer list
|
|
48
|
+
cannot round integers or disguise booleans during dtype inference.
|
|
49
|
+
"""
|
|
50
|
+
raw = values if isinstance(values, np.ndarray) else np.asarray(values, dtype=object)
|
|
51
|
+
if raw.ndim != 1:
|
|
52
|
+
raise ValueError(f"values must be 1-D, got an array with {raw.ndim} dimension(s)")
|
|
53
|
+
if raw.dtype.kind not in "fiuO":
|
|
54
|
+
raise ValueError("observations must be real numeric values")
|
|
55
|
+
if raw.dtype.kind == "O":
|
|
56
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
57
|
+
return np.fromiter((_scalar(item) for item in raw), dtype=np.float64, count=len(raw))
|
|
58
|
+
with np.errstate(over="ignore", invalid="ignore"):
|
|
59
|
+
converted = np.asarray(raw, dtype=np.float64)
|
|
60
|
+
if raw.dtype.kind in "iu":
|
|
61
|
+
# A value that rounds to the integer type's limit is not exact, and the
|
|
62
|
+
# out-of-range cast back is architecture dependent (x86 wraps, ARM
|
|
63
|
+
# saturates to the original value), so reject it before casting.
|
|
64
|
+
limit = 2.0 ** (raw.dtype.itemsize * 8 - (raw.dtype.kind == "i"))
|
|
65
|
+
if np.any(converted >= limit) or not np.array_equal(
|
|
66
|
+
raw, converted.astype(raw.dtype)
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("integer observations cannot be represented exactly as Float64")
|
|
69
|
+
elif raw.dtype.itemsize > 8 and np.any(np.isfinite(raw) & (raw != converted)):
|
|
70
|
+
raise ValueError("observations cannot be represented exactly as Float64")
|
|
71
|
+
return converted
|
qavf/_version.py
ADDED
qavf/aggregation.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Exact accumulator selection and typed reductions shared by execution backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal, localcontext
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
|
|
9
|
+
from qavf.numeric import exact_number, stable_sum_scalar
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def accumulator_dtype(dtype: pl.DataType) -> pl.DataType:
|
|
13
|
+
"""Storage for integer/Decimal sums, retaining the input decimal scale."""
|
|
14
|
+
return pl.Decimal(38, dtype.scale or 0) if isinstance(dtype, pl.Decimal) else pl.Int128()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def accumulator_sql(dtype: pl.DataType) -> str:
|
|
18
|
+
"""Native SQL storage with the same width and scale as typed accumulation."""
|
|
19
|
+
storage = accumulator_dtype(dtype)
|
|
20
|
+
return f"DECIMAL(38,{storage.scale})" if isinstance(storage, pl.Decimal) else "HUGEINT"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def checked_accumulator_dtype(
|
|
24
|
+
dtype: pl.DataType,
|
|
25
|
+
minimum: object,
|
|
26
|
+
maximum: object,
|
|
27
|
+
count: int,
|
|
28
|
+
) -> pl.DataType:
|
|
29
|
+
"""Prove a conservative sum bound from scalar metadata, without reading rows.
|
|
30
|
+
|
|
31
|
+
Callers supply their existing bound on contributing rows. This guard deliberately
|
|
32
|
+
rejects a potentially overflowing sum even when cancellation could make it fit.
|
|
33
|
+
"""
|
|
34
|
+
if not (minimum is None or isinstance(minimum, int | float | Decimal)) or not (
|
|
35
|
+
maximum is None or isinstance(maximum, int | float | Decimal)
|
|
36
|
+
):
|
|
37
|
+
raise TypeError("numeric aggregation returned a nonnumeric extremum")
|
|
38
|
+
magnitude = max(exact_number(minimum or 0).copy_abs(), exact_number(maximum or 0).copy_abs())
|
|
39
|
+
storage = accumulator_dtype(dtype)
|
|
40
|
+
with localcontext() as context:
|
|
41
|
+
context.prec = 100
|
|
42
|
+
ceiling = (
|
|
43
|
+
Decimal(10) ** (38 - (storage.scale or 0))
|
|
44
|
+
if isinstance(storage, pl.Decimal)
|
|
45
|
+
else 2**127
|
|
46
|
+
)
|
|
47
|
+
if magnitude and magnitude * count >= ceiling:
|
|
48
|
+
raise ValueError("exact sum may exceed accumulator precision; partition the data")
|
|
49
|
+
return storage
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def numeric_expr(column: str, dtype: pl.DataType) -> pl.Expr:
|
|
53
|
+
"""Preserve exact integers/decimals and normalize floating missing values."""
|
|
54
|
+
value = pl.col(column)
|
|
55
|
+
return value.fill_nan(None) if dtype.is_float() else value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def exact_sum_expr(series: pl.Series) -> pl.Expr:
|
|
59
|
+
"""Overflow-checked sum; null-only input remains an undefined measurement."""
|
|
60
|
+
dtype = series.dtype
|
|
61
|
+
value = numeric_expr(series.name, dtype)
|
|
62
|
+
if dtype.is_float():
|
|
63
|
+
return value.map_batches(stable_sum_scalar, return_dtype=pl.Float64, returns_scalar=True)
|
|
64
|
+
if dtype.is_integer() or dtype.is_decimal():
|
|
65
|
+
value = value.cast(
|
|
66
|
+
checked_accumulator_dtype(dtype, series.min(), series.max(), series.len())
|
|
67
|
+
)
|
|
68
|
+
return pl.when(value.count() > 0).then(value.sum()).otherwise(None)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def narrow_integer(frame: pl.DataFrame, column: str) -> pl.DataFrame:
|
|
72
|
+
"""Keep ordinary integer results compact, retaining Int128 when required."""
|
|
73
|
+
if frame.schema[column] != pl.Int128:
|
|
74
|
+
return frame
|
|
75
|
+
lo, hi = frame[column].min(), frame[column].max()
|
|
76
|
+
if lo is None:
|
|
77
|
+
return frame.with_columns(pl.col(column).cast(pl.Int64))
|
|
78
|
+
if not isinstance(lo, int) or not isinstance(hi, int):
|
|
79
|
+
raise TypeError("integer aggregation returned a noninteger extremum")
|
|
80
|
+
if lo >= -(2**63) and hi < 2**63:
|
|
81
|
+
return frame.with_columns(pl.col(column).cast(pl.Int64))
|
|
82
|
+
if lo >= 0 and hi < 2**64:
|
|
83
|
+
return frame.with_columns(pl.col(column).cast(pl.UInt64))
|
|
84
|
+
return frame
|
qavf/api.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Public validation orchestration over explicit input descriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from qavf.engine.runner import QARunner
|
|
9
|
+
from qavf.inputs.normalize import Configuration, prepare
|
|
10
|
+
from qavf.inputs.resources import ResourceLimits
|
|
11
|
+
from qavf.reports.models import QAReport
|
|
12
|
+
from qavf.settings import SchemaSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate(
|
|
16
|
+
data: object | Mapping[str, object] | None = None,
|
|
17
|
+
*,
|
|
18
|
+
schema: SchemaSpec | Mapping[str, Any] | None = None,
|
|
19
|
+
config: Configuration = None,
|
|
20
|
+
resources: ResourceLimits | None = None,
|
|
21
|
+
) -> QAReport:
|
|
22
|
+
"""Validate explicit contracts and return a report without writing files.
|
|
23
|
+
|
|
24
|
+
Pass a path, File, frame, Query, Loader, or named mapping of those inputs.
|
|
25
|
+
Dataset wraps an input with its own schema, foreign keys, expressions and
|
|
26
|
+
loading safeguards. Plain mappings always name datasets, never columns.
|
|
27
|
+
All input names and contracts are checked before adapting or executing data.
|
|
28
|
+
Lazy frames, deferred loaders and caller-owned handles keep their ownership.
|
|
29
|
+
"""
|
|
30
|
+
settings, frames = prepare(data, schema=schema, config=config, resources=resources)
|
|
31
|
+
return QARunner(settings).run(frames=frames)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["validate"]
|