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.
Files changed (155) hide show
  1. qavf/__init__.py +128 -0
  2. qavf/_audit_temporal.py +43 -0
  3. qavf/_float_accumulator.py +52 -0
  4. qavf/_observations.py +71 -0
  5. qavf/_version.py +3 -0
  6. qavf/aggregation.py +84 -0
  7. qavf/api.py +34 -0
  8. qavf/audit_codec.py +428 -0
  9. qavf/cli.py +429 -0
  10. qavf/demo.py +236 -0
  11. qavf/demo_config.yaml +155 -0
  12. qavf/docgen.py +191 -0
  13. qavf/engine/__init__.py +1 -0
  14. qavf/engine/cache_ownership.py +372 -0
  15. qavf/engine/capabilities.py +27 -0
  16. qavf/engine/catalog.py +285 -0
  17. qavf/engine/compilation.py +216 -0
  18. qavf/engine/connections.py +199 -0
  19. qavf/engine/context.py +1322 -0
  20. qavf/engine/observation.py +369 -0
  21. qavf/engine/observation_plan.py +204 -0
  22. qavf/engine/owned_ipc.py +31 -0
  23. qavf/engine/planning.py +135 -0
  24. qavf/engine/projection.py +76 -0
  25. qavf/engine/registry.py +493 -0
  26. qavf/engine/relations.py +27 -0
  27. qavf/engine/resources.py +425 -0
  28. qavf/engine/runner.py +912 -0
  29. qavf/engine/session.py +133 -0
  30. qavf/engine/severity.py +80 -0
  31. qavf/engine/source_read.py +148 -0
  32. qavf/engine/spill.py +129 -0
  33. qavf/engine/sql.py +172 -0
  34. qavf/engine/sql_read.py +227 -0
  35. qavf/engine/sqlite_store.py +128 -0
  36. qavf/engine/thread_budget.py +23 -0
  37. qavf/execution/__init__.py +0 -0
  38. qavf/execution/_windows_job.py +279 -0
  39. qavf/execution/bootstrap.py +61 -0
  40. qavf/execution/dispatch.py +46 -0
  41. qavf/execution/errors.py +11 -0
  42. qavf/execution/inputs.py +33 -0
  43. qavf/execution/os_limits.py +87 -0
  44. qavf/execution/policy.py +8 -0
  45. qavf/execution/protocol.py +86 -0
  46. qavf/execution/supervisor.py +434 -0
  47. qavf/execution/worker.py +99 -0
  48. qavf/inputs/__init__.py +4 -0
  49. qavf/inputs/adapters.py +136 -0
  50. qavf/inputs/descriptors.py +201 -0
  51. qavf/inputs/normalize.py +285 -0
  52. qavf/inputs/resources.py +120 -0
  53. qavf/io.py +109 -0
  54. qavf/key_codec.py +35 -0
  55. qavf/keys.py +112 -0
  56. qavf/numeric.py +150 -0
  57. qavf/ordering.py +59 -0
  58. qavf/output_format.py +35 -0
  59. qavf/profiling/__init__.py +108 -0
  60. qavf/profiling/accumulator.py +133 -0
  61. qavf/profiling/values.py +134 -0
  62. qavf/py.typed +0 -0
  63. qavf/redaction.py +221 -0
  64. qavf/regression/__init__.py +28 -0
  65. qavf/regression/_baseline_writer.py +176 -0
  66. qavf/regression/_cells.py +125 -0
  67. qavf/regression/_columns.py +10 -0
  68. qavf/regression/_comparison.py +270 -0
  69. qavf/regression/_evidence.py +167 -0
  70. qavf/regression/_indexed.py +205 -0
  71. qavf/regression/_inputs.py +146 -0
  72. qavf/regression/_numeric.py +96 -0
  73. qavf/regression/_plan.py +208 -0
  74. qavf/regression/_schema.py +77 -0
  75. qavf/regression/_streaming.py +221 -0
  76. qavf/regression/baselines.py +357 -0
  77. qavf/regression/compare_outputs.py +282 -0
  78. qavf/regression/models.py +163 -0
  79. qavf/reports/__init__.py +1 -0
  80. qavf/reports/charts.py +101 -0
  81. qavf/reports/html.py +287 -0
  82. qavf/reports/models.py +508 -0
  83. qavf/reports/presentation.py +117 -0
  84. qavf/reports/templates/records.html.j2 +12 -0
  85. qavf/reports/templates/report.html.j2 +288 -0
  86. qavf/rules/__init__.py +1 -0
  87. qavf/rules/_column_batch.py +235 -0
  88. qavf/rules/_column_plan.py +543 -0
  89. qavf/rules/_column_rows.py +131 -0
  90. qavf/rules/_column_stream.py +110 -0
  91. qavf/rules/_contracts.py +58 -0
  92. qavf/rules/_coordinate_sql.py +98 -0
  93. qavf/rules/_coverage.py +247 -0
  94. qavf/rules/_distribution.py +170 -0
  95. qavf/rules/_domains.py +77 -0
  96. qavf/rules/_exact_grouped.py +196 -0
  97. qavf/rules/_execution.py +334 -0
  98. qavf/rules/_grid.py +210 -0
  99. qavf/rules/_grouped.py +338 -0
  100. qavf/rules/_key_summary.py +131 -0
  101. qavf/rules/_ordered_grouped.py +107 -0
  102. qavf/rules/_outlier_batches.py +298 -0
  103. qavf/rules/_predicates.py +378 -0
  104. qavf/rules/_regex.py +32 -0
  105. qavf/rules/_row_summary.py +97 -0
  106. qavf/rules/_spatial_aggregation.py +120 -0
  107. qavf/rules/_statistics_plan.py +69 -0
  108. qavf/rules/_temporal.py +82 -0
  109. qavf/rules/freshness_checks.py +205 -0
  110. qavf/rules/geo_checks.py +244 -0
  111. qavf/rules/helpers.py +468 -0
  112. qavf/rules/input_checks/__init__.py +23 -0
  113. qavf/rules/input_checks/_column_eager.py +347 -0
  114. qavf/rules/input_checks/_column_simple.py +326 -0
  115. qavf/rules/input_checks/_column_values.py +192 -0
  116. qavf/rules/input_checks/_datasets.py +107 -0
  117. qavf/rules/input_checks/availability.py +192 -0
  118. qavf/rules/input_checks/columns.py +160 -0
  119. qavf/rules/input_checks/expressions.py +184 -0
  120. qavf/rules/input_checks/keys.py +260 -0
  121. qavf/rules/input_checks/references.py +432 -0
  122. qavf/rules/reconciliation_checks.py +1020 -0
  123. qavf/rules/regression_checks.py +250 -0
  124. qavf/rules/spatial_checks.py +881 -0
  125. qavf/rules/statistical_checks.py +411 -0
  126. qavf/rules/temporal_checks.py +1654 -0
  127. qavf/scaffold.py +241 -0
  128. qavf/settings.py +1046 -0
  129. qavf/sources/__init__.py +41 -0
  130. qavf/sources/base.py +424 -0
  131. qavf/sources/builtin.py +17 -0
  132. qavf/sources/collection.py +18 -0
  133. qavf/sources/errors.py +61 -0
  134. qavf/sources/execution.py +104 -0
  135. qavf/sources/file.py +483 -0
  136. qavf/sources/formats.py +111 -0
  137. qavf/sources/frames.py +44 -0
  138. qavf/sources/identity.py +89 -0
  139. qavf/sources/memory.py +48 -0
  140. qavf/sources/native.py +165 -0
  141. qavf/sources/parameters.py +152 -0
  142. qavf/sources/python.py +52 -0
  143. qavf/sources/query.py +108 -0
  144. qavf/sources/sql.py +452 -0
  145. qavf/sources/typed.py +40 -0
  146. qavf/statistics/__init__.py +1 -0
  147. qavf/statistics/ordered.py +189 -0
  148. qavf/stats.py +600 -0
  149. qavf/testing.py +115 -0
  150. qavf/typed_values.py +64 -0
  151. qavf-0.4.0.dist-info/METADATA +257 -0
  152. qavf-0.4.0.dist-info/RECORD +155 -0
  153. qavf-0.4.0.dist-info/WHEEL +4 -0
  154. qavf-0.4.0.dist-info/entry_points.txt +2 -0
  155. 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__))
@@ -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
@@ -0,0 +1,3 @@
1
+ """Single source of the package version."""
2
+
3
+ __version__ = "0.4.0"
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"]