dsh-data-quality 0.1.3 → 0.3.0
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.
- package/CHANGELOG.md +33 -0
- package/README.es.md +7 -1
- package/README.hi.md +7 -1
- package/README.md +22 -9
- package/README.pt.md +7 -1
- package/README.zh.md +7 -1
- package/cordis.patch.yml +11 -0
- package/lib/index.js +1609 -145
- package/lib/types/config.d.ts +12 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +35 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/contract.d.ts +83 -0
- package/lib/types/contract.d.ts.map +1 -0
- package/lib/types/contract.js +144 -0
- package/lib/types/contract.js.map +1 -0
- package/lib/types/dataset.d.ts +50 -3
- package/lib/types/dataset.d.ts.map +1 -1
- package/lib/types/dataset.js +76 -21
- package/lib/types/dataset.js.map +1 -1
- package/lib/types/events.d.ts +2 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +13 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +17 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/presets.d.ts +28 -0
- package/lib/types/presets.d.ts.map +1 -0
- package/lib/types/presets.js +134 -0
- package/lib/types/presets.js.map +1 -0
- package/lib/types/profile.d.ts +40 -1
- package/lib/types/profile.d.ts.map +1 -1
- package/lib/types/profile.js +66 -10
- package/lib/types/profile.js.map +1 -1
- package/lib/types/provider-local.d.ts +7 -1
- package/lib/types/provider-local.d.ts.map +1 -1
- package/lib/types/provider-local.js +72 -2
- package/lib/types/provider-local.js.map +1 -1
- package/lib/types/report-html.d.ts +41 -0
- package/lib/types/report-html.d.ts.map +1 -0
- package/lib/types/report-html.js +192 -0
- package/lib/types/report-html.js.map +1 -0
- package/lib/types/scorecard.d.ts +50 -0
- package/lib/types/scorecard.d.ts.map +1 -0
- package/lib/types/scorecard.js +186 -0
- package/lib/types/scorecard.js.map +1 -0
- package/lib/types/service.d.ts +32 -2
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js.map +1 -1
- package/lib/types/store.d.ts +21 -1
- package/lib/types/store.d.ts.map +1 -1
- package/lib/types/store.js +13 -1
- package/lib/types/store.js.map +1 -1
- package/lib/types/tools/clean.d.ts.map +1 -1
- package/lib/types/tools/clean.js +76 -1
- package/lib/types/tools/clean.js.map +1 -1
- package/lib/types/tools/profile-report-schema.d.ts +319 -0
- package/lib/types/tools/profile-report-schema.d.ts.map +1 -0
- package/lib/types/tools/profile-report-schema.js +96 -0
- package/lib/types/tools/profile-report-schema.js.map +1 -0
- package/lib/types/tools/profile.d.ts.map +1 -1
- package/lib/types/tools/profile.js +6 -53
- package/lib/types/tools/profile.js.map +1 -1
- package/lib/types/tools/report.d.ts +14 -0
- package/lib/types/tools/report.d.ts.map +1 -0
- package/lib/types/tools/report.js +112 -0
- package/lib/types/tools/report.js.map +1 -0
- package/lib/types/tools/shared.d.ts.map +1 -1
- package/lib/types/tools/shared.js +22 -2
- package/lib/types/tools/shared.js.map +1 -1
- package/lib/types/tools/verify.d.ts.map +1 -1
- package/lib/types/tools/verify.js +36 -1
- package/lib/types/tools/verify.js.map +1 -1
- package/lib/types/verify.d.ts +49 -3
- package/lib/types/verify.d.ts.map +1 -1
- package/lib/types/verify.js +111 -4
- package/lib/types/verify.js.map +1 -1
- package/lib/types/version.d.ts +8 -1
- package/lib/types/version.d.ts.map +1 -1
- package/lib/types/version.js +8 -1
- package/lib/types/version.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +52 -0
- package/src/contract.ts +190 -0
- package/src/dataset.ts +101 -21
- package/src/events.ts +2 -0
- package/src/index.ts +27 -8
- package/src/presets.ts +146 -0
- package/src/profile.ts +105 -11
- package/src/provider-local.ts +77 -3
- package/src/report-html.ts +208 -0
- package/src/scorecard.ts +244 -0
- package/src/service.ts +34 -2
- package/src/store.ts +28 -2
- package/src/tools/clean.ts +76 -1
- package/src/tools/profile-report-schema.ts +98 -0
- package/src/tools/profile.ts +8 -56
- package/src/tools/report.ts +137 -0
- package/src/tools/shared.ts +21 -2
- package/src/tools/verify.ts +40 -2
- package/src/verify.ts +151 -5
- package/src/version.ts +9 -1
package/src/presets.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in industry profile presets: a deterministic registry of expected
|
|
3
|
+
* column lists (name -> declared type) per industry. When `data_profile` is
|
|
4
|
+
* called with `industryPreset`, the preset's columns feed the scorecard's
|
|
5
|
+
* `declaredSchema` so the `accuracy` dimension becomes determinable instead
|
|
6
|
+
* of undetermined. Unknown preset ids fail loud.
|
|
7
|
+
* @module dsh-data-quality/presets
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { InferredType } from './profile.ts'
|
|
11
|
+
|
|
12
|
+
/** One industry preset: an id, a human label, and the expected columns with their declared types. */
|
|
13
|
+
export interface IndustryPreset {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly label: string
|
|
16
|
+
/** Expected column name -> declared type (fed to the scorecard as `declaredSchema`). */
|
|
17
|
+
readonly columns: Readonly<Record<string, InferredType>>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The built-in industry preset registry. */
|
|
21
|
+
export const INDUSTRY_PRESETS: Readonly<Record<string, IndustryPreset>> = {
|
|
22
|
+
retail: {
|
|
23
|
+
id: 'retail',
|
|
24
|
+
label: 'Retail',
|
|
25
|
+
columns: {
|
|
26
|
+
order_id: 'string',
|
|
27
|
+
sku: 'string',
|
|
28
|
+
quantity: 'number',
|
|
29
|
+
unit_price: 'number',
|
|
30
|
+
revenue: 'number',
|
|
31
|
+
order_date: 'date',
|
|
32
|
+
customer_id: 'string',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
saas: {
|
|
36
|
+
id: 'saas',
|
|
37
|
+
label: 'SaaS',
|
|
38
|
+
columns: {
|
|
39
|
+
account_id: 'string',
|
|
40
|
+
plan: 'string',
|
|
41
|
+
seats: 'number',
|
|
42
|
+
mrr: 'number',
|
|
43
|
+
signup_date: 'date',
|
|
44
|
+
churned: 'boolean',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
fund: {
|
|
48
|
+
id: 'fund',
|
|
49
|
+
label: 'Fund',
|
|
50
|
+
columns: {
|
|
51
|
+
fund_code: 'string',
|
|
52
|
+
fund_name: 'string',
|
|
53
|
+
nav: 'number',
|
|
54
|
+
nav_date: 'date',
|
|
55
|
+
holding_value: 'number',
|
|
56
|
+
currency: 'string',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
'real-estate': {
|
|
60
|
+
id: 'real-estate',
|
|
61
|
+
label: 'Real estate',
|
|
62
|
+
columns: {
|
|
63
|
+
property_id: 'string',
|
|
64
|
+
listing_price: 'number',
|
|
65
|
+
area_sqm: 'number',
|
|
66
|
+
bedrooms: 'number',
|
|
67
|
+
list_date: 'date',
|
|
68
|
+
city: 'string',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
'e-commerce': {
|
|
72
|
+
id: 'e-commerce',
|
|
73
|
+
label: 'E-commerce',
|
|
74
|
+
columns: {
|
|
75
|
+
order_id: 'string',
|
|
76
|
+
product_id: 'string',
|
|
77
|
+
quantity: 'number',
|
|
78
|
+
price: 'number',
|
|
79
|
+
order_date: 'date',
|
|
80
|
+
status: 'string',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
healthcare: {
|
|
84
|
+
id: 'healthcare',
|
|
85
|
+
label: 'Healthcare',
|
|
86
|
+
columns: {
|
|
87
|
+
patient_id: 'string',
|
|
88
|
+
admission_date: 'date',
|
|
89
|
+
discharge_date: 'date',
|
|
90
|
+
age: 'number',
|
|
91
|
+
diagnosis: 'string',
|
|
92
|
+
cost: 'number',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
logistics: {
|
|
96
|
+
id: 'logistics',
|
|
97
|
+
label: 'Logistics',
|
|
98
|
+
columns: {
|
|
99
|
+
shipment_id: 'string',
|
|
100
|
+
origin: 'string',
|
|
101
|
+
destination: 'string',
|
|
102
|
+
weight_kg: 'number',
|
|
103
|
+
shipped_date: 'date',
|
|
104
|
+
delivered: 'boolean',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
manufacturing: {
|
|
108
|
+
id: 'manufacturing',
|
|
109
|
+
label: 'Manufacturing',
|
|
110
|
+
columns: {
|
|
111
|
+
part_id: 'string',
|
|
112
|
+
quantity: 'number',
|
|
113
|
+
unit_cost: 'number',
|
|
114
|
+
produced_date: 'date',
|
|
115
|
+
defect: 'boolean',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
energy: {
|
|
119
|
+
id: 'energy',
|
|
120
|
+
label: 'Energy',
|
|
121
|
+
columns: {
|
|
122
|
+
meter_id: 'string',
|
|
123
|
+
reading: 'number',
|
|
124
|
+
unit: 'string',
|
|
125
|
+
reading_date: 'date',
|
|
126
|
+
consumption: 'number',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The preset ids, for diagnostics and documentation. */
|
|
132
|
+
export const INDUSTRY_PRESET_IDS = Object.keys(INDUSTRY_PRESETS) as readonly string[]
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve an industry preset id to its registry entry, failing loud on an
|
|
136
|
+
* unknown id.
|
|
137
|
+
* @param id - the requested preset id.
|
|
138
|
+
* @returns the preset.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveIndustryPreset(id: string): IndustryPreset {
|
|
141
|
+
const preset = INDUSTRY_PRESETS[id]
|
|
142
|
+
if (preset === undefined) {
|
|
143
|
+
throw new Error(`unknown industryPreset ${JSON.stringify(id)}; choose one of: ${INDUSTRY_PRESET_IDS.join(', ')}`)
|
|
144
|
+
}
|
|
145
|
+
return preset
|
|
146
|
+
}
|
package/src/profile.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* @module dsh-data-quality/profile
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import { isMissing, parseBoolean, parseDate, parseNumeric, sampleRows, throwIfAborted, type EncodingInfo, type Row, type Table } from './dataset.ts'
|
|
11
|
+
import { computeScorecard, type DataQualityScorecard, type ScorecardDimensionName } from './scorecard.ts'
|
|
12
|
+
import { REPORT_SCHEMA_VERSION } from './version.ts'
|
|
10
13
|
|
|
11
14
|
/** Inferred column type from parsed cell classes. */
|
|
12
15
|
export type InferredType = 'number' | 'date' | 'boolean' | 'string' | 'empty' | 'mixed'
|
|
13
16
|
|
|
14
17
|
/** Numeric distribution of one numeric column. */
|
|
15
18
|
export interface NumericProfile {
|
|
19
|
+
/** Number of numeric values the distribution covers. */
|
|
20
|
+
readonly count: number
|
|
21
|
+
/** Distinct numeric values among the profiled rows. */
|
|
22
|
+
readonly distinct: number
|
|
16
23
|
readonly min: number
|
|
17
24
|
readonly max: number
|
|
18
25
|
readonly mean: number
|
|
@@ -41,8 +48,20 @@ export interface ColumnProfile {
|
|
|
41
48
|
notes: string[]
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
/** Full-content duplicate detection over ALL rows. */
|
|
52
|
+
export interface DuplicateDetection {
|
|
53
|
+
/** Rows whose full sha256 content duplicates an earlier row. */
|
|
54
|
+
readonly duplicateRows: number
|
|
55
|
+
/** `duplicateRows / rowCount` (0 when the table has no rows). */
|
|
56
|
+
readonly duplicateRate: number
|
|
57
|
+
/** 0-based indexes of duplicated rows, capped by the sample limit. */
|
|
58
|
+
readonly duplicateSampleRowIndexes: number[]
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** The full profile report (also the persisted and tool-returned value). */
|
|
45
62
|
export interface ProfileReport {
|
|
63
|
+
/** The persisted-report schema version (see {@link REPORT_SCHEMA_VERSION}). */
|
|
64
|
+
readonly schemaVersion: number
|
|
46
65
|
readonly dataset: string
|
|
47
66
|
readonly rowCount: number
|
|
48
67
|
/** Whether column cards describe a systematic sample rather than every row. */
|
|
@@ -52,6 +71,14 @@ export interface ProfileReport {
|
|
|
52
71
|
readonly columnCount: number
|
|
53
72
|
/** Rows whose full content duplicates an earlier row (over ALL rows). */
|
|
54
73
|
readonly duplicateRows: number
|
|
74
|
+
/** `duplicateRows / rowCount` (0 when the table has no rows). */
|
|
75
|
+
readonly duplicateRate: number
|
|
76
|
+
/** 0-based indexes of duplicated rows (capped by the configured sample limit). */
|
|
77
|
+
readonly duplicateSampleRowIndexes: number[]
|
|
78
|
+
/** DAMA six-dimension quality scorecard. */
|
|
79
|
+
readonly scorecard: DataQualityScorecard
|
|
80
|
+
/** Detected file encoding (BOM/UTF-8 validity); present only for file-loaded tables. */
|
|
81
|
+
readonly encoding?: EncodingInfo
|
|
55
82
|
columns: ColumnProfile[]
|
|
56
83
|
/** Storage-domain key of the persisted report, when persistence is on (set by the provider). */
|
|
57
84
|
readonly reportKey?: string
|
|
@@ -59,6 +86,9 @@ export interface ProfileReport {
|
|
|
59
86
|
readonly generatedAt: number
|
|
60
87
|
}
|
|
61
88
|
|
|
89
|
+
/** Fallback duplicate-sample cap for direct engine use; the provider always passes the configured `evidenceRowLimit`. */
|
|
90
|
+
const DEFAULT_DUPLICATE_SAMPLE_LIMIT = 20
|
|
91
|
+
|
|
62
92
|
/** Round to 6 significant digits for stable, readable report numbers. */
|
|
63
93
|
function round6(value: number): number {
|
|
64
94
|
return Number(value.toPrecision(6))
|
|
@@ -86,6 +116,8 @@ export function numericProfile(values: readonly number[]): NumericProfile | unde
|
|
|
86
116
|
const highFence = p75 + 1.5 * iqr
|
|
87
117
|
const outliers = iqr === 0 ? 0 : sorted.filter((value) => value < lowFence || value > highFence).length
|
|
88
118
|
return {
|
|
119
|
+
count: sorted.length,
|
|
120
|
+
distinct: new Set(sorted).size,
|
|
89
121
|
min: round6(sorted[0] as number),
|
|
90
122
|
max: round6(sorted[sorted.length - 1] as number),
|
|
91
123
|
mean: round6(sum / sorted.length),
|
|
@@ -96,20 +128,49 @@ export function numericProfile(values: readonly number[]): NumericProfile | unde
|
|
|
96
128
|
}
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
131
|
+
/** Deterministic sha256 key of one row's full content (columns in table order). */
|
|
132
|
+
function rowContentKey(table: Table, row: Row): string {
|
|
133
|
+
return createHash('sha256').update(JSON.stringify(table.columns.map((column) => row[column] ?? null))).digest('hex')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Detect full-content duplicate rows with a bounded sample of their 0-based
|
|
138
|
+
* indexes. The first occurrence of each content is never counted; later rows
|
|
139
|
+
* with identical full content are duplicates.
|
|
140
|
+
* @param table - the parsed dataset.
|
|
141
|
+
* @param options - sample cap and optional abort signal.
|
|
142
|
+
* @returns the duplicate count, rate, and capped sample indexes.
|
|
143
|
+
*/
|
|
144
|
+
export function detectDuplicateRows(
|
|
145
|
+
table: Table,
|
|
146
|
+
options: { sampleLimit: number; signal?: AbortSignal | undefined },
|
|
147
|
+
): DuplicateDetection {
|
|
148
|
+
if (!Number.isSafeInteger(options.sampleLimit) || options.sampleLimit <= 0) {
|
|
149
|
+
throw new TypeError(`sampleLimit must be a positive safe integer, got ${String(options.sampleLimit)}`)
|
|
150
|
+
}
|
|
101
151
|
const seen = new Set<string>()
|
|
102
152
|
let duplicates = 0
|
|
153
|
+
const duplicateSampleRowIndexes: number[] = []
|
|
103
154
|
for (const [index, row] of table.rows.entries()) {
|
|
104
|
-
if (index % 1024 === 0) throwIfAborted(signal)
|
|
105
|
-
const key =
|
|
155
|
+
if (index % 1024 === 0) throwIfAborted(options.signal)
|
|
156
|
+
const key = rowContentKey(table, row)
|
|
106
157
|
if (seen.has(key)) {
|
|
107
158
|
duplicates += 1
|
|
159
|
+
if (duplicateSampleRowIndexes.length < options.sampleLimit) duplicateSampleRowIndexes.push(index)
|
|
108
160
|
} else {
|
|
109
161
|
seen.add(key)
|
|
110
162
|
}
|
|
111
163
|
}
|
|
112
|
-
return
|
|
164
|
+
return {
|
|
165
|
+
duplicateRows: duplicates,
|
|
166
|
+
duplicateRate: table.rows.length === 0 ? 0 : round6(duplicates / table.rows.length),
|
|
167
|
+
duplicateSampleRowIndexes,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Count rows whose full content duplicates an earlier row (first occurrence is not counted). */
|
|
172
|
+
export function countDuplicateRows(table: Table, signal?: AbortSignal): number {
|
|
173
|
+
return detectDuplicateRows(table, { sampleLimit: 1, signal }).duplicateRows
|
|
113
174
|
}
|
|
114
175
|
|
|
115
176
|
/** Profile one column over the given rows. */
|
|
@@ -206,19 +267,39 @@ function profileColumn(rows: readonly Row[], column: string, signal?: AbortSigna
|
|
|
206
267
|
*/
|
|
207
268
|
export function profileTable(
|
|
208
269
|
table: Table,
|
|
209
|
-
options: {
|
|
270
|
+
options: {
|
|
271
|
+
dataset: string
|
|
272
|
+
sample?: number | undefined
|
|
273
|
+
generatedAt: number
|
|
274
|
+
signal?: AbortSignal | undefined
|
|
275
|
+
duplicateSampleLimit?: number | undefined
|
|
276
|
+
declaredSchema?: Readonly<Record<string, InferredType>> | undefined
|
|
277
|
+
scorecardWeights?: Readonly<Record<ScorecardDimensionName, number>> | undefined
|
|
278
|
+
},
|
|
210
279
|
): ProfileReport {
|
|
211
280
|
throwIfAborted(options.signal)
|
|
212
281
|
const profiled = options.sample === undefined ? table.rows : sampleRows(table.rows, options.sample)
|
|
213
282
|
const columns = table.columns.map((column) => profileColumn(profiled, column, options.signal))
|
|
214
|
-
const
|
|
283
|
+
const detection = detectDuplicateRows(table, { sampleLimit: options.duplicateSampleLimit ?? DEFAULT_DUPLICATE_SAMPLE_LIMIT, signal: options.signal })
|
|
284
|
+
const scorecard = computeScorecard(table, {
|
|
285
|
+
now: options.generatedAt,
|
|
286
|
+
duplicateRows: detection.duplicateRows,
|
|
287
|
+
declaredSchema: options.declaredSchema,
|
|
288
|
+
weights: options.scorecardWeights,
|
|
289
|
+
signal: options.signal,
|
|
290
|
+
})
|
|
215
291
|
return {
|
|
292
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
216
293
|
dataset: options.dataset,
|
|
217
294
|
rowCount: table.rows.length,
|
|
218
295
|
sampled: profiled.length !== table.rows.length,
|
|
219
296
|
profiledRows: profiled.length,
|
|
220
297
|
columnCount: table.columns.length,
|
|
221
|
-
duplicateRows,
|
|
298
|
+
duplicateRows: detection.duplicateRows,
|
|
299
|
+
duplicateRate: detection.duplicateRate,
|
|
300
|
+
duplicateSampleRowIndexes: detection.duplicateSampleRowIndexes,
|
|
301
|
+
scorecard,
|
|
302
|
+
...(table.encoding !== undefined ? { encoding: table.encoding } : {}),
|
|
222
303
|
columns,
|
|
223
304
|
generatedAt: options.generatedAt,
|
|
224
305
|
}
|
|
@@ -229,14 +310,17 @@ export function renderProfileText(report: ProfileReport): string {
|
|
|
229
310
|
const lines: string[] = []
|
|
230
311
|
lines.push(`Profile of ${report.dataset}: ${report.rowCount} rows x ${report.columnCount} columns` +
|
|
231
312
|
(report.sampled ? ` (column cards over a systematic sample of ${report.profiledRows} rows)` : ''))
|
|
232
|
-
if (report.duplicateRows > 0)
|
|
313
|
+
if (report.duplicateRows > 0) {
|
|
314
|
+
lines.push(`Duplicate rows: ${report.duplicateRows} (${(report.duplicateRate * 100).toFixed(1)}%)` +
|
|
315
|
+
(report.duplicateSampleRowIndexes.length > 0 ? `; sample row indexes: ${report.duplicateSampleRowIndexes.join(', ')}` : ''))
|
|
316
|
+
}
|
|
233
317
|
for (const column of report.columns) {
|
|
234
318
|
const parts = [`${column.name}: ${column.inferredType}`]
|
|
235
319
|
if (column.missing > 0) parts.push(`missing ${column.missing} (${(column.missingRate * 100).toFixed(1)}%)`)
|
|
236
320
|
parts.push(`unique ${column.unique}`)
|
|
237
321
|
if (column.numeric !== undefined) {
|
|
238
322
|
parts.push(
|
|
239
|
-
`min ${column.numeric.min}, p25 ${column.numeric.p25}, median ${column.numeric.median}, p75 ${column.numeric.p75}, max ${column.numeric.max}, mean ${column.numeric.mean}` +
|
|
323
|
+
`count ${column.numeric.count}, distinct ${column.numeric.distinct}, min ${column.numeric.min}, p25 ${column.numeric.p25}, median ${column.numeric.median}, p75 ${column.numeric.p75}, max ${column.numeric.max}, mean ${column.numeric.mean}` +
|
|
240
324
|
(column.numeric.outliers > 0 ? `, ${column.numeric.outliers} IQR outliers` : ''),
|
|
241
325
|
)
|
|
242
326
|
}
|
|
@@ -246,5 +330,15 @@ export function renderProfileText(report: ProfileReport): string {
|
|
|
246
330
|
for (const note of column.notes) parts.push(`note: ${note}`)
|
|
247
331
|
lines.push(`- ${parts.join('; ')}`)
|
|
248
332
|
}
|
|
333
|
+
if (report.encoding !== undefined) {
|
|
334
|
+
lines.push(`Encoding: UTF-8${report.encoding.bom === 'utf-8' ? ' (BOM)' : ''}${report.encoding.validUtf8 ? '' : ' (INVALID UTF-8)'}`)
|
|
335
|
+
}
|
|
336
|
+
const overall = report.scorecard.overall
|
|
337
|
+
const weighted = report.scorecard.weightedOverall
|
|
338
|
+
lines.push(`Scorecard (overall ${overall === null ? 'undetermined' : `${(overall * 100).toFixed(1)}%`}, weighted ${weighted === null ? 'undetermined' : `${(weighted * 100).toFixed(1)}%`}):`)
|
|
339
|
+
for (const dimension of report.scorecard.dimensions) {
|
|
340
|
+
const value = dimension.score === null ? 'undetermined' : `${(dimension.score * 100).toFixed(1)}%`
|
|
341
|
+
lines.push(` ${dimension.name}: ${value} (${dimension.note})`)
|
|
342
|
+
}
|
|
249
343
|
return lines.join('\n')
|
|
250
344
|
}
|
package/src/provider-local.ts
CHANGED
|
@@ -12,10 +12,12 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
12
12
|
import type { ResolvedConfig } from './config.ts'
|
|
13
13
|
import { loadDocument, loadTable, resolveWorkspacePath, throwIfAborted } from './dataset.ts'
|
|
14
14
|
import { applyCleanRules, serializeDelimited } from './clean.ts'
|
|
15
|
+
import { computeCleanContract, computeCleanProfileDiff } from './contract.ts'
|
|
15
16
|
import { profileTable } from './profile.ts'
|
|
16
17
|
import { checkCitations, verifyTable } from './verify.ts'
|
|
17
18
|
import { appendDataQualityEvent } from './events.ts'
|
|
18
|
-
import { reportKeyOf, type ReportRecord, type ReportStore } from './store.ts'
|
|
19
|
+
import { isValidReportKey, reportKeyOf, type ReportRecord, type ReportStore, type StoredReport } from './store.ts'
|
|
20
|
+
import { resolveIndustryPreset } from './presets.ts'
|
|
19
21
|
import { truncateRow } from './present.ts'
|
|
20
22
|
import {
|
|
21
23
|
DataQualityService,
|
|
@@ -73,7 +75,7 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/** Emit the adaptive session event when the call carries a session. */
|
|
76
|
-
emitEvent(session: Session | undefined, kind: 'profile' | 'clean' | 'verify', dataset: string, reportKey: string | undefined, summary: { rows: number; columns?: number; rules?: number; failedRules?: number; passed?: boolean }): void {
|
|
78
|
+
emitEvent(session: Session | undefined, kind: 'profile' | 'clean' | 'verify', dataset: string, reportKey: string | undefined, summary: { rows: number; columns?: number; rules?: number; failedRules?: number; passed?: boolean; expectations?: number; failedExpectations?: number }): void {
|
|
77
79
|
if (session === undefined) return
|
|
78
80
|
appendDataQualityEvent(session, `data-quality/${kind}`, {
|
|
79
81
|
kind,
|
|
@@ -88,11 +90,15 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
88
90
|
throwIfAborted(request.signal)
|
|
89
91
|
const absolute = resolveWorkspacePath(request.workspace, request.dataset, this.config)
|
|
90
92
|
const table = await loadTable(absolute, this.config, request.signal)
|
|
93
|
+
const declaredSchema = request.industryPreset === undefined ? undefined : resolveIndustryPreset(request.industryPreset).columns
|
|
91
94
|
const report = profileTable(table, {
|
|
92
95
|
dataset: request.dataset,
|
|
93
96
|
sample: request.sample,
|
|
94
97
|
generatedAt: this.deps.now(),
|
|
95
98
|
signal: request.signal,
|
|
99
|
+
duplicateSampleLimit: this.config.evidenceRowLimit,
|
|
100
|
+
declaredSchema,
|
|
101
|
+
scorecardWeights: this.config.scorecardWeights,
|
|
96
102
|
})
|
|
97
103
|
const reportKey = await this.persist('profile', request.dataset, report as unknown as Record<string, unknown>)
|
|
98
104
|
this.emitEvent(request.session, 'profile', request.dataset, reportKey, { rows: report.rowCount, columns: report.columnCount })
|
|
@@ -102,12 +108,13 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
102
108
|
/** @inheritdoc DataQualityService.cleanDataset */
|
|
103
109
|
override async cleanDataset(request: CleanRequest): Promise<CleanRunReport> {
|
|
104
110
|
throwIfAborted(request.signal)
|
|
111
|
+
const dryRun = request.dryRun === true
|
|
105
112
|
const absolute = resolveWorkspacePath(request.workspace, request.dataset, this.config)
|
|
106
113
|
const table = await loadTable(absolute, this.config, request.signal)
|
|
107
114
|
const result = applyCleanRules(table, request.rules, { signal: request.signal })
|
|
108
115
|
|
|
109
116
|
let writtenPath: string | undefined
|
|
110
|
-
if (request.outputPath !== undefined) {
|
|
117
|
+
if (!dryRun && request.outputPath !== undefined) {
|
|
111
118
|
const outputAbsolute = resolveWorkspacePath(request.workspace, request.outputPath, this.config)
|
|
112
119
|
if (outputAbsolute === absolute) {
|
|
113
120
|
throw new Error(`outputPath ${JSON.stringify(request.outputPath)} would overwrite the input dataset; choose a different path`)
|
|
@@ -125,26 +132,63 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
const generatedAt = this.deps.now()
|
|
135
|
+
// The contract summary is part of the model-visible canonical value, so it
|
|
136
|
+
// must also be part of the durable report (model-visible ⟺ logged).
|
|
137
|
+
const contract = computeCleanContract(result, request.rules, { signal: request.signal })
|
|
138
|
+
const outputTable = {
|
|
139
|
+
columns: result.columns,
|
|
140
|
+
rows: result.rows,
|
|
141
|
+
...(table.encoding !== undefined ? { encoding: table.encoding } : {}),
|
|
142
|
+
}
|
|
143
|
+
// The before/after profile diff preview is the expected diff in a dry run
|
|
144
|
+
// and the durable audit artifact (separate `clean-diff` record) otherwise.
|
|
145
|
+
const diff = computeCleanProfileDiff(table, outputTable, {
|
|
146
|
+
dataset: request.dataset,
|
|
147
|
+
generatedAt,
|
|
148
|
+
scorecardWeights: this.config.scorecardWeights,
|
|
149
|
+
signal: request.signal,
|
|
150
|
+
})
|
|
128
151
|
// The preview is part of the model-visible canonical value, so it must
|
|
129
152
|
// also be part of the durable report: otherwise the clean result's preview
|
|
130
153
|
// could not be reconstructed from the session log (model-visible ⟺ logged).
|
|
131
154
|
const preview = { columns: result.columns, rows: result.rows.slice(0, this.config.evidenceRowLimit).map((row) => truncateRow(row)) }
|
|
155
|
+
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
this.emitEvent(request.session, 'clean', request.dataset, undefined, { rows: result.outputRows, columns: result.columns.length, rules: result.logs.length })
|
|
158
|
+
return {
|
|
159
|
+
dataset: request.dataset,
|
|
160
|
+
inputRows: result.inputRows,
|
|
161
|
+
outputRows: result.outputRows,
|
|
162
|
+
dryRun: true,
|
|
163
|
+
logs: result.logs,
|
|
164
|
+
contract,
|
|
165
|
+
preview,
|
|
166
|
+
diffPreview: diff,
|
|
167
|
+
generatedAt,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
132
171
|
const reportKey = await this.persist('clean', request.dataset, {
|
|
133
172
|
dataset: request.dataset,
|
|
134
173
|
inputRows: result.inputRows,
|
|
135
174
|
outputRows: result.outputRows,
|
|
175
|
+
dryRun: false,
|
|
136
176
|
logs: result.logs,
|
|
177
|
+
contract,
|
|
137
178
|
preview,
|
|
138
179
|
...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
|
|
139
180
|
generatedAt,
|
|
140
181
|
} as unknown as Record<string, unknown>)
|
|
182
|
+
await this.persist('clean-diff', request.dataset, diff as unknown as Record<string, unknown>)
|
|
141
183
|
this.emitEvent(request.session, 'clean', request.dataset, reportKey, { rows: result.outputRows, columns: result.columns.length, rules: result.logs.length })
|
|
142
184
|
|
|
143
185
|
return {
|
|
144
186
|
dataset: request.dataset,
|
|
145
187
|
inputRows: result.inputRows,
|
|
146
188
|
outputRows: result.outputRows,
|
|
189
|
+
dryRun: false,
|
|
147
190
|
logs: result.logs,
|
|
191
|
+
contract,
|
|
148
192
|
preview,
|
|
149
193
|
...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
|
|
150
194
|
...(reportKey !== undefined ? { reportKey } : {}),
|
|
@@ -152,6 +196,31 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
152
196
|
}
|
|
153
197
|
}
|
|
154
198
|
|
|
199
|
+
/** @inheritdoc DataQualityService.getReport */
|
|
200
|
+
override async getReport(key: string): Promise<StoredReport> {
|
|
201
|
+
if (!isValidReportKey(key)) {
|
|
202
|
+
throw new Error(`invalid reportKey ${JSON.stringify(key)}: expected the deterministic <timestamp>-<kind>-<fingerprint> format`)
|
|
203
|
+
}
|
|
204
|
+
const store = this.deps.store
|
|
205
|
+
if (store === undefined) {
|
|
206
|
+
throw new Error('report storage is disabled (storeReports is false); no persisted reports to read')
|
|
207
|
+
}
|
|
208
|
+
const record = store.get(key)
|
|
209
|
+
if (record === undefined) {
|
|
210
|
+
throw new Error(`no persisted report found for reportKey ${JSON.stringify(key)}`)
|
|
211
|
+
}
|
|
212
|
+
return { key, ...record }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** @inheritdoc DataQualityService.listReports */
|
|
216
|
+
override async listReports(kind: ReportRecord['kind']): Promise<StoredReport[]> {
|
|
217
|
+
const store = this.deps.store
|
|
218
|
+
if (store === undefined) {
|
|
219
|
+
throw new Error('report storage is disabled (storeReports is false); no persisted reports to read')
|
|
220
|
+
}
|
|
221
|
+
return store.list(kind)
|
|
222
|
+
}
|
|
223
|
+
|
|
155
224
|
/** @inheritdoc DataQualityService.verifyDataset */
|
|
156
225
|
override async verifyDataset(request: VerifyRequest): Promise<VerifyReport> {
|
|
157
226
|
throwIfAborted(request.signal)
|
|
@@ -161,15 +230,20 @@ export class LocalDataQualityService extends DataQualityService {
|
|
|
161
230
|
evidenceRowLimit: this.config.evidenceRowLimit,
|
|
162
231
|
now: this.deps.now,
|
|
163
232
|
signal: request.signal,
|
|
233
|
+
expectations: request.expectations,
|
|
234
|
+
defaultTolerance: this.config.defaultTolerance,
|
|
164
235
|
})
|
|
165
236
|
const report: VerifyReport = { dataset: request.dataset, ...outcome }
|
|
166
237
|
const failedRules = report.rules.filter((rule) => !rule.passed).length
|
|
238
|
+
const failedExpectations = report.expectations.filter((expectation) => !expectation.passed).length
|
|
167
239
|
const reportKey = await this.persist('verify', request.dataset, report as unknown as Record<string, unknown>)
|
|
168
240
|
this.emitEvent(request.session, 'verify', request.dataset, reportKey, {
|
|
169
241
|
rows: report.rowCount,
|
|
170
242
|
rules: report.rules.length,
|
|
171
243
|
failedRules,
|
|
172
244
|
passed: report.passed,
|
|
245
|
+
expectations: report.expectations.length,
|
|
246
|
+
failedExpectations,
|
|
173
247
|
})
|
|
174
248
|
return { ...report, ...(reportKey !== undefined ? { reportKey } : {}) }
|
|
175
249
|
}
|