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.
Files changed (103) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +7 -1
  3. package/README.hi.md +7 -1
  4. package/README.md +22 -9
  5. package/README.pt.md +7 -1
  6. package/README.zh.md +7 -1
  7. package/cordis.patch.yml +11 -0
  8. package/lib/index.js +1609 -145
  9. package/lib/types/config.d.ts +12 -0
  10. package/lib/types/config.d.ts.map +1 -1
  11. package/lib/types/config.js +35 -0
  12. package/lib/types/config.js.map +1 -1
  13. package/lib/types/contract.d.ts +83 -0
  14. package/lib/types/contract.d.ts.map +1 -0
  15. package/lib/types/contract.js +144 -0
  16. package/lib/types/contract.js.map +1 -0
  17. package/lib/types/dataset.d.ts +50 -3
  18. package/lib/types/dataset.d.ts.map +1 -1
  19. package/lib/types/dataset.js +76 -21
  20. package/lib/types/dataset.js.map +1 -1
  21. package/lib/types/events.d.ts +2 -0
  22. package/lib/types/events.d.ts.map +1 -1
  23. package/lib/types/events.js.map +1 -1
  24. package/lib/types/index.d.ts +13 -8
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +17 -6
  27. package/lib/types/index.js.map +1 -1
  28. package/lib/types/presets.d.ts +28 -0
  29. package/lib/types/presets.d.ts.map +1 -0
  30. package/lib/types/presets.js +134 -0
  31. package/lib/types/presets.js.map +1 -0
  32. package/lib/types/profile.d.ts +40 -1
  33. package/lib/types/profile.d.ts.map +1 -1
  34. package/lib/types/profile.js +66 -10
  35. package/lib/types/profile.js.map +1 -1
  36. package/lib/types/provider-local.d.ts +7 -1
  37. package/lib/types/provider-local.d.ts.map +1 -1
  38. package/lib/types/provider-local.js +72 -2
  39. package/lib/types/provider-local.js.map +1 -1
  40. package/lib/types/report-html.d.ts +41 -0
  41. package/lib/types/report-html.d.ts.map +1 -0
  42. package/lib/types/report-html.js +192 -0
  43. package/lib/types/report-html.js.map +1 -0
  44. package/lib/types/scorecard.d.ts +50 -0
  45. package/lib/types/scorecard.d.ts.map +1 -0
  46. package/lib/types/scorecard.js +186 -0
  47. package/lib/types/scorecard.js.map +1 -0
  48. package/lib/types/service.d.ts +32 -2
  49. package/lib/types/service.d.ts.map +1 -1
  50. package/lib/types/service.js.map +1 -1
  51. package/lib/types/store.d.ts +21 -1
  52. package/lib/types/store.d.ts.map +1 -1
  53. package/lib/types/store.js +13 -1
  54. package/lib/types/store.js.map +1 -1
  55. package/lib/types/tools/clean.d.ts.map +1 -1
  56. package/lib/types/tools/clean.js +76 -1
  57. package/lib/types/tools/clean.js.map +1 -1
  58. package/lib/types/tools/profile-report-schema.d.ts +319 -0
  59. package/lib/types/tools/profile-report-schema.d.ts.map +1 -0
  60. package/lib/types/tools/profile-report-schema.js +96 -0
  61. package/lib/types/tools/profile-report-schema.js.map +1 -0
  62. package/lib/types/tools/profile.d.ts.map +1 -1
  63. package/lib/types/tools/profile.js +6 -53
  64. package/lib/types/tools/profile.js.map +1 -1
  65. package/lib/types/tools/report.d.ts +14 -0
  66. package/lib/types/tools/report.d.ts.map +1 -0
  67. package/lib/types/tools/report.js +112 -0
  68. package/lib/types/tools/report.js.map +1 -0
  69. package/lib/types/tools/shared.d.ts.map +1 -1
  70. package/lib/types/tools/shared.js +22 -2
  71. package/lib/types/tools/shared.js.map +1 -1
  72. package/lib/types/tools/verify.d.ts.map +1 -1
  73. package/lib/types/tools/verify.js +36 -1
  74. package/lib/types/tools/verify.js.map +1 -1
  75. package/lib/types/verify.d.ts +49 -3
  76. package/lib/types/verify.d.ts.map +1 -1
  77. package/lib/types/verify.js +111 -4
  78. package/lib/types/verify.js.map +1 -1
  79. package/lib/types/version.d.ts +8 -1
  80. package/lib/types/version.d.ts.map +1 -1
  81. package/lib/types/version.js +8 -1
  82. package/lib/types/version.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/config.ts +52 -0
  85. package/src/contract.ts +190 -0
  86. package/src/dataset.ts +101 -21
  87. package/src/events.ts +2 -0
  88. package/src/index.ts +27 -8
  89. package/src/presets.ts +146 -0
  90. package/src/profile.ts +105 -11
  91. package/src/provider-local.ts +77 -3
  92. package/src/report-html.ts +208 -0
  93. package/src/scorecard.ts +244 -0
  94. package/src/service.ts +34 -2
  95. package/src/store.ts +28 -2
  96. package/src/tools/clean.ts +76 -1
  97. package/src/tools/profile-report-schema.ts +98 -0
  98. package/src/tools/profile.ts +8 -56
  99. package/src/tools/report.ts +137 -0
  100. package/src/tools/shared.ts +21 -2
  101. package/src/tools/verify.ts +40 -2
  102. package/src/verify.ts +151 -5
  103. 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 { isMissing, parseBoolean, parseDate, parseNumeric, sampleRows, throwIfAborted, type Row, type Table } from './dataset.ts'
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
- /** Count rows whose full content duplicates an earlier row (first occurrence is not counted). */
100
- export function countDuplicateRows(table: Table, signal?: AbortSignal): number {
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 = JSON.stringify(table.columns.map((column) => row[column] ?? null))
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 duplicates
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: { dataset: string; sample?: number | undefined; generatedAt: number; signal?: AbortSignal | undefined },
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 duplicateRows = countDuplicateRows(table, options.signal)
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) lines.push(`Duplicate rows: ${report.duplicateRows}`)
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
  }
@@ -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
  }