Files
0pt/src/quality/expectation-engine.ts
T
Simon AxelssonandClaude Fable 5 b46807da75 feat(data-os): P0 capability layer — source registry, search, quality, dataset versions, metadata, analytics, features, ER
- Source Registry (§10): rights/license/purpose metadata, provenance,
  freshness SLA, health checks, describe/query; purpose gate FAILS CLOSED
  (no rights record => deny); cross-tenant probes => NOT_FOUND.
- Search (§10/§42): OpenSearch REST adapter (no SDK) + SearchService with
  MANDATORY tenant + purpose/rights filtering, versioned index definitions,
  bulk, generation-per-rebuild (opt-{name}--g{n}) with atomic alias cutover,
  rollback, failed-rebuild generation discard. OpenSearch never canonical.
- Quality (§10): versioned rule registry (GE-JSON interchange), local
  engine for the GE core subset (never silently passes unsupported types),
  runs/results/issues/score/trend, whole-run delegation to the GE backend
  via N0D (0pt.quality.ge_run) - never fabricated results.
- Dataset (§10/§5/§53): immutable numbered versions holding canonical
  st0re:artifact refs only, idempotent createVersion, export registers the
  ST0RE artifact + AUD0 evidence + OpenLineage.
- Metadata (§10): OpenMetadata PUT /api/v1/tables projection adapter +
  projectAll rebuild path; projection never canonical.
- Analytics (§10): ClickHouse HTTP adapter, closed metric-query DSL with
  FORCED tenant predicate + readonly/max_execution_time/max_result_rows,
  TTL retention. Zero connection to b0ts/quarantined pipelines.
- Features (§10): versioned feature-view registry, real Feast
  get-online-features adapter, materialize/get-historical as N0D delegation.
- ER (§10): generic candidate API with deterministic blocking + transparent
  scoring + per-field explanations, product hard-rule hook (force both
  directions), review-band handoff, Splink batch via N0D contract.

93 new unit/contract tests; suite 248 passed / 1 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:49:32 +02:00

159 lines
5.6 KiB
TypeScript

/**
* src/quality/expectation-engine.ts
* ==================================
* The LOCAL executor for the core Great Expectations expectation subset
* (Master-DoD §10 "Great Expectations-integrationen verklig" — the
* interchange format IS GE's; execution is either this local engine for the
* supported subset, or delegated to the GE backend in 0pt-motor via N0D for
* everything else — §11 DQ "Great Expectations executed").
*
* HONESTY RULE: an unsupported expectation type NEVER silently passes.
* `supports()` says what this engine can run; QualityService delegates or
* fails loudly for the rest.
*
* Supported (semantics per GE docs, element-wise unless noted):
* expect_column_values_to_not_be_null {column}
* expect_column_values_to_be_unique {column}
* expect_column_values_to_be_between {column, min_value?, max_value?}
* expect_column_values_to_match_regex {column, regex}
* expect_column_values_to_be_in_set {column, value_set}
* expect_table_row_count_to_be_between {min_value?, max_value?} (table-level)
* expect_column_to_exist {column} (table-level)
*/
import { SiaxError } from "../platform/errors";
import type { QualityExpectation } from "./quality-types";
export interface ExpectationOutcome {
success: boolean;
pass_ratio: number;
evaluated_count: number;
unexpected_count: number;
unexpected_samples: unknown[];
}
const SUPPORTED = new Set([
"expect_column_values_to_not_be_null",
"expect_column_values_to_be_unique",
"expect_column_values_to_be_between",
"expect_column_values_to_match_regex",
"expect_column_values_to_be_in_set",
"expect_table_row_count_to_be_between",
"expect_column_to_exist",
]);
const MAX_SAMPLES = 20;
export class ExpectationEngine {
supports(expectationType: string): boolean {
return SUPPORTED.has(expectationType);
}
supportedTypes(): string[] {
return [...SUPPORTED];
}
execute(expectation: QualityExpectation, rows: Record<string, unknown>[]): ExpectationOutcome {
const { expectation_type: type, kwargs } = expectation;
if (!this.supports(type)) {
throw new SiaxError(
"CAPABILITY_UNAVAILABLE",
"0pt.quality",
`expectation_type "${type}" is not executable by the local engine — delegate to the GE backend`
);
}
const column = kwargs.column as string | undefined;
switch (type) {
case "expect_column_to_exist": {
const exists = rows.length === 0 || rows.some((r) => column! in r);
return tableOutcome(exists);
}
case "expect_table_row_count_to_be_between": {
const min = numOrUndefined(kwargs.min_value);
const max = numOrUndefined(kwargs.max_value);
const n = rows.length;
const ok = (min === undefined || n >= min) && (max === undefined || n <= max);
return { ...tableOutcome(ok), evaluated_count: n };
}
case "expect_column_values_to_not_be_null": {
return elementwise(rows, column!, (v) => v !== null && v !== undefined);
}
case "expect_column_values_to_be_unique": {
const seen = new Map<string, number>();
for (const r of rows) {
const key = JSON.stringify(r[column!] ?? null);
seen.set(key, (seen.get(key) ?? 0) + 1);
}
return elementwise(rows, column!, (v) => (seen.get(JSON.stringify(v ?? null)) ?? 0) <= 1);
}
case "expect_column_values_to_be_between": {
const min = numOrUndefined(kwargs.min_value);
const max = numOrUndefined(kwargs.max_value);
return elementwise(rows, column!, (v) => {
if (v === null || v === undefined) return true; // GE semantics: nulls don't fail range checks
const n = Number(v);
if (!Number.isFinite(n)) return false;
return (min === undefined || n >= min) && (max === undefined || n <= max);
});
}
case "expect_column_values_to_match_regex": {
const re = new RegExp(String(kwargs.regex));
return elementwise(rows, column!, (v) => {
if (v === null || v === undefined) return true;
return re.test(String(v));
});
}
case "expect_column_values_to_be_in_set": {
const set = new Set((kwargs.value_set as unknown[]) ?? []);
return elementwise(rows, column!, (v) => {
if (v === null || v === undefined) return true;
return set.has(v);
});
}
default:
// Unreachable (guarded by supports()), kept for exhaustiveness.
throw new SiaxError("CAPABILITY_UNAVAILABLE", "0pt.quality", `unhandled expectation "${type}"`);
}
}
}
function tableOutcome(ok: boolean): ExpectationOutcome {
return {
success: ok,
pass_ratio: ok ? 1 : 0,
evaluated_count: 1,
unexpected_count: ok ? 0 : 1,
unexpected_samples: [],
};
}
function numOrUndefined(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function elementwise(
rows: Record<string, unknown>[],
column: string,
predicate: (v: unknown) => boolean
): ExpectationOutcome {
let unexpected = 0;
const samples: unknown[] = [];
for (const r of rows) {
const v = r[column];
if (!predicate(v)) {
unexpected++;
if (samples.length < MAX_SAMPLES) samples.push(v ?? null);
}
}
const evaluated = rows.length;
return {
success: unexpected === 0,
pass_ratio: evaluated === 0 ? 1 : (evaluated - unexpected) / evaluated,
evaluated_count: evaluated,
unexpected_count: unexpected,
unexpected_samples: samples,
};
}