dsh-data-quality 0.1.2 → 0.2.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 (96) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.es.md +8 -2
  3. package/README.hi.md +8 -2
  4. package/README.md +22 -9
  5. package/README.pt.md +8 -2
  6. package/README.zh.md +8 -2
  7. package/cordis.patch.yml +11 -0
  8. package/lib/index.js +1411 -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 +11 -8
  25. package/lib/types/index.d.ts.map +1 -1
  26. package/lib/types/index.js +15 -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 +38 -1
  33. package/lib/types/profile.d.ts.map +1 -1
  34. package/lib/types/profile.js +64 -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 +78 -4
  39. package/lib/types/provider-local.js.map +1 -1
  40. package/lib/types/scorecard.d.ts +50 -0
  41. package/lib/types/scorecard.d.ts.map +1 -0
  42. package/lib/types/scorecard.js +186 -0
  43. package/lib/types/scorecard.js.map +1 -0
  44. package/lib/types/service.d.ts +32 -2
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js.map +1 -1
  47. package/lib/types/store.d.ts +21 -1
  48. package/lib/types/store.d.ts.map +1 -1
  49. package/lib/types/store.js +13 -1
  50. package/lib/types/store.js.map +1 -1
  51. package/lib/types/tools/clean.d.ts.map +1 -1
  52. package/lib/types/tools/clean.js +76 -1
  53. package/lib/types/tools/clean.js.map +1 -1
  54. package/lib/types/tools/profile-report-schema.d.ts +315 -0
  55. package/lib/types/tools/profile-report-schema.d.ts.map +1 -0
  56. package/lib/types/tools/profile-report-schema.js +95 -0
  57. package/lib/types/tools/profile-report-schema.js.map +1 -0
  58. package/lib/types/tools/profile.d.ts.map +1 -1
  59. package/lib/types/tools/profile.js +6 -53
  60. package/lib/types/tools/profile.js.map +1 -1
  61. package/lib/types/tools/report.d.ts +14 -0
  62. package/lib/types/tools/report.d.ts.map +1 -0
  63. package/lib/types/tools/report.js +86 -0
  64. package/lib/types/tools/report.js.map +1 -0
  65. package/lib/types/tools/shared.d.ts.map +1 -1
  66. package/lib/types/tools/shared.js +22 -2
  67. package/lib/types/tools/shared.js.map +1 -1
  68. package/lib/types/tools/verify.d.ts.map +1 -1
  69. package/lib/types/tools/verify.js +36 -1
  70. package/lib/types/tools/verify.js.map +1 -1
  71. package/lib/types/verify.d.ts +49 -3
  72. package/lib/types/verify.d.ts.map +1 -1
  73. package/lib/types/verify.js +111 -4
  74. package/lib/types/verify.js.map +1 -1
  75. package/lib/types/version.d.ts +1 -1
  76. package/lib/types/version.js +1 -1
  77. package/package.json +1 -1
  78. package/src/config.ts +52 -0
  79. package/src/contract.ts +190 -0
  80. package/src/dataset.ts +101 -21
  81. package/src/events.ts +2 -0
  82. package/src/index.ts +25 -8
  83. package/src/presets.ts +146 -0
  84. package/src/profile.ts +101 -11
  85. package/src/provider-local.ts +83 -5
  86. package/src/scorecard.ts +244 -0
  87. package/src/service.ts +34 -2
  88. package/src/store.ts +28 -2
  89. package/src/tools/clean.ts +76 -1
  90. package/src/tools/profile-report-schema.ts +97 -0
  91. package/src/tools/profile.ts +8 -56
  92. package/src/tools/report.ts +108 -0
  93. package/src/tools/shared.ts +21 -2
  94. package/src/tools/verify.ts +40 -2
  95. package/src/verify.ts +151 -5
  96. package/src/version.ts +1 -1
@@ -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,29 +132,95 @@ 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
+ })
151
+ // The preview is part of the model-visible canonical value, so it must
152
+ // also be part of the durable report: otherwise the clean result's preview
153
+ // could not be reconstructed from the session log (model-visible ⟺ logged).
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
+
128
171
  const reportKey = await this.persist('clean', request.dataset, {
129
172
  dataset: request.dataset,
130
173
  inputRows: result.inputRows,
131
174
  outputRows: result.outputRows,
175
+ dryRun: false,
132
176
  logs: result.logs,
177
+ contract,
178
+ preview,
133
179
  ...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
134
180
  generatedAt,
135
181
  } as unknown as Record<string, unknown>)
182
+ await this.persist('clean-diff', request.dataset, diff as unknown as Record<string, unknown>)
136
183
  this.emitEvent(request.session, 'clean', request.dataset, reportKey, { rows: result.outputRows, columns: result.columns.length, rules: result.logs.length })
137
184
 
138
- const previewRows = result.rows.slice(0, this.config.evidenceRowLimit).map((row) => truncateRow(row))
139
185
  return {
140
186
  dataset: request.dataset,
141
187
  inputRows: result.inputRows,
142
188
  outputRows: result.outputRows,
189
+ dryRun: false,
143
190
  logs: result.logs,
144
- preview: { columns: result.columns, rows: previewRows },
191
+ contract,
192
+ preview,
145
193
  ...(writtenPath !== undefined ? { outputPath: writtenPath } : {}),
146
194
  ...(reportKey !== undefined ? { reportKey } : {}),
147
195
  generatedAt,
148
196
  }
149
197
  }
150
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
+
151
224
  /** @inheritdoc DataQualityService.verifyDataset */
152
225
  override async verifyDataset(request: VerifyRequest): Promise<VerifyReport> {
153
226
  throwIfAborted(request.signal)
@@ -157,15 +230,20 @@ export class LocalDataQualityService extends DataQualityService {
157
230
  evidenceRowLimit: this.config.evidenceRowLimit,
158
231
  now: this.deps.now,
159
232
  signal: request.signal,
233
+ expectations: request.expectations,
234
+ defaultTolerance: this.config.defaultTolerance,
160
235
  })
161
236
  const report: VerifyReport = { dataset: request.dataset, ...outcome }
162
237
  const failedRules = report.rules.filter((rule) => !rule.passed).length
238
+ const failedExpectations = report.expectations.filter((expectation) => !expectation.passed).length
163
239
  const reportKey = await this.persist('verify', request.dataset, report as unknown as Record<string, unknown>)
164
240
  this.emitEvent(request.session, 'verify', request.dataset, reportKey, {
165
241
  rows: report.rowCount,
166
242
  rules: report.rules.length,
167
243
  failedRules,
168
244
  passed: report.passed,
245
+ expectations: report.expectations.length,
246
+ failedExpectations,
169
247
  })
170
248
  return { ...report, ...(reportKey !== undefined ? { reportKey } : {}) }
171
249
  }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * DAMA-style six-dimension quality scorecard over a parsed {@link Table}.
3
+ * Pure and deterministic: every rate derives from one full-table pass, the
4
+ * only clock is the injected `now`, and no dimension fabricates a score it
5
+ * cannot defend — `accuracy` stays `null` (undetermined) without a declared
6
+ * schema and `timeliness` stays `null` without date cells.
7
+ * @module dsh-data-quality/scorecard
8
+ */
9
+
10
+ import { isMissing, parseBoolean, parseDateCell, parseNumeric, throwIfAborted, type Table } from './dataset.ts'
11
+ import type { InferredType } from './profile.ts'
12
+
13
+ /** The six scorecard dimension ids, in report order. */
14
+ export type ScorecardDimensionName = 'completeness' | 'uniqueness' | 'validity' | 'consistency' | 'timeliness' | 'accuracy'
15
+
16
+ /** One scorecard dimension: a 0..1 rate, or `null` when it cannot be determined. */
17
+ export interface ScorecardDimension {
18
+ readonly name: ScorecardDimensionName
19
+ /** Rate in [0, 1] rounded to 6 significant digits; `null` = cannot be determined (never fabricated). */
20
+ readonly score: number | null
21
+ /** Human-readable definition and verdict. */
22
+ readonly note: string
23
+ }
24
+
25
+ /** The six-dimension quality scorecard (also the profile report's `scorecard` field). */
26
+ export interface DataQualityScorecard {
27
+ readonly dimensions: ScorecardDimension[]
28
+ /** Unweighted mean of the determinable (non-null) dimension scores; `null` when none are determinable. */
29
+ readonly overall: number | null
30
+ /** Weighted mean over the determinable dimensions; `null` when none are determinable or all their weights are 0. */
31
+ readonly weightedOverall: number | null
32
+ }
33
+
34
+ /** Per-column aggregation for one full-table pass. */
35
+ interface ColumnStats {
36
+ present: number
37
+ number: number
38
+ date: number
39
+ boolean: number
40
+ string: number
41
+ /** Format-label frequency for consistency. */
42
+ formats: Map<string, number>
43
+ }
44
+
45
+ /** Round to 6 significant digits (mirrors the profile report's rounding). */
46
+ function round6(value: number): number {
47
+ return Number(value.toPrecision(6))
48
+ }
49
+
50
+ /** Increment a format-label counter. */
51
+ function bump(formats: Map<string, number>, tag: string): void {
52
+ formats.set(tag, (formats.get(tag) ?? 0) + 1)
53
+ }
54
+
55
+ /** Inferred column type from the full-table class counts (mirrors `profileColumn`). */
56
+ function inferType(stats: ColumnStats): InferredType {
57
+ if (stats.present === 0) return 'empty'
58
+ if (stats.number === stats.present) return 'number'
59
+ if (stats.date === stats.present) return 'date'
60
+ if (stats.boolean === stats.present) return 'boolean'
61
+ if (stats.string === stats.present) return 'string'
62
+ return 'mixed'
63
+ }
64
+
65
+ /** Format the `value / total` ratio as a plain `note` fragment. */
66
+ function ratioNote(numerator: number, denominator: number): string {
67
+ return `${numerator}/${denominator}`
68
+ }
69
+
70
+ /** Build one determinable dimension with its ratio note. */
71
+ function dimension(name: ScorecardDimensionName, score: number, numerator: number, denominator: number): ScorecardDimension {
72
+ return { name, score, note: ratioNote(numerator, denominator) }
73
+ }
74
+
75
+ /**
76
+ * Compute the six-dimension scorecard over a full table:
77
+ * - `completeness` — non-empty cell rate.
78
+ * - `uniqueness` — unique full-row content rate (`1 - duplicateRows / rowCount`).
79
+ * - `validity` — present cells conforming to their column's inferred type.
80
+ * - `consistency` — present cells in their column's dominant format (a date
81
+ * column's unified `YYYY-MM-DD` vs `YYYY/MM/DD` vs datetime ratio).
82
+ * - `timeliness` — date cells not future-dated relative to the injected `now`.
83
+ * - `accuracy` — declared-schema agreement (limited definition); `null`
84
+ * (undetermined) without a declared schema — never fabricated.
85
+ * @param table - the parsed dataset (full table, not the sampled cards).
86
+ * @param options - injected clock, duplicate-row count, optional declared schema, optional weights, abort signal.
87
+ * @returns the scorecard.
88
+ */
89
+ export function computeScorecard(
90
+ table: Table,
91
+ options: {
92
+ now: number
93
+ duplicateRows: number
94
+ declaredSchema?: Readonly<Record<string, InferredType>> | undefined
95
+ weights?: Readonly<Record<ScorecardDimensionName, number>> | undefined
96
+ signal?: AbortSignal | undefined
97
+ },
98
+ ): DataQualityScorecard {
99
+ throwIfAborted(options.signal)
100
+ const stats = new Map<string, ColumnStats>()
101
+ for (const column of table.columns) {
102
+ stats.set(column, { present: 0, number: 0, date: 0, boolean: 0, string: 0, formats: new Map() })
103
+ }
104
+ let missingCells = 0
105
+ let dateCells = 0
106
+ let futureDateCells = 0
107
+
108
+ for (const [index, row] of table.rows.entries()) {
109
+ if (index % 1024 === 0) throwIfAborted(options.signal)
110
+ for (const column of table.columns) {
111
+ const cell = row[column]
112
+ const columnStats = stats.get(column) as ColumnStats
113
+ if (isMissing(cell)) {
114
+ missingCells += 1
115
+ continue
116
+ }
117
+ columnStats.present += 1
118
+ const numeric = parseNumeric(cell)
119
+ if (numeric !== undefined) {
120
+ columnStats.number += 1
121
+ bump(columnStats.formats, 'number')
122
+ continue
123
+ }
124
+ const date = parseDateCell(cell)
125
+ if (date !== undefined) {
126
+ columnStats.date += 1
127
+ bump(columnStats.formats, date.format)
128
+ dateCells += 1
129
+ if (date.epoch > options.now) futureDateCells += 1
130
+ continue
131
+ }
132
+ if (parseBoolean(cell) !== undefined) {
133
+ columnStats.boolean += 1
134
+ bump(columnStats.formats, 'boolean')
135
+ continue
136
+ }
137
+ columnStats.string += 1
138
+ bump(columnStats.formats, typeof cell === 'string' ? 'string' : 'json')
139
+ }
140
+ }
141
+
142
+ const totalCells = table.rows.length * table.columns.length
143
+ let presentCells = 0
144
+ let invalidCells = 0
145
+ let consistentCells = 0
146
+ for (const [, columnStats] of stats) {
147
+ presentCells += columnStats.present
148
+ // Validity = conformance to the column's dominant TYPE class; consistency
149
+ // below measures dominant FORMAT (so a date column with mixed YYYY-MM-DD /
150
+ // YYYY/MM/DD formats stays valid but inconsistent).
151
+ const dominantClass = Math.max(columnStats.number, columnStats.date, columnStats.boolean, columnStats.string)
152
+ invalidCells += columnStats.present - dominantClass
153
+ if (columnStats.present === 0) continue
154
+ let dominant = 0
155
+ for (const count of columnStats.formats.values()) {
156
+ if (count > dominant) dominant = count
157
+ }
158
+ consistentCells += dominant
159
+ }
160
+
161
+ const completeness: ScorecardDimension =
162
+ totalCells === 0
163
+ ? { name: 'completeness', score: null, note: 'undetermined: no cells' }
164
+ : dimension('completeness', round6(1 - missingCells / totalCells), presentCells, totalCells)
165
+
166
+ const uniqueness: ScorecardDimension =
167
+ table.rows.length === 0
168
+ ? { name: 'uniqueness', score: null, note: 'undetermined: no rows' }
169
+ : dimension('uniqueness', round6(1 - options.duplicateRows / table.rows.length), table.rows.length - options.duplicateRows, table.rows.length)
170
+
171
+ const validity: ScorecardDimension =
172
+ presentCells === 0
173
+ ? { name: 'validity', score: null, note: 'undetermined: no present cells' }
174
+ : dimension('validity', round6(1 - invalidCells / presentCells), presentCells - invalidCells, presentCells)
175
+
176
+ const consistency: ScorecardDimension =
177
+ presentCells === 0
178
+ ? { name: 'consistency', score: null, note: 'undetermined: no present cells' }
179
+ : dimension('consistency', round6(consistentCells / presentCells), consistentCells, presentCells)
180
+
181
+ const timeliness: ScorecardDimension =
182
+ dateCells === 0
183
+ ? { name: 'timeliness', score: null, note: 'undetermined: no date cells' }
184
+ : dimension('timeliness', round6(1 - futureDateCells / dateCells), dateCells - futureDateCells, dateCells)
185
+
186
+ const accuracy = computeAccuracy(stats, table, options.declaredSchema)
187
+
188
+ const dimensions = [completeness, uniqueness, validity, consistency, timeliness, accuracy]
189
+ const scores = dimensions.map((entry) => entry.score).filter((score): score is number => score !== null)
190
+ const overall = scores.length === 0 ? null : round6(scores.reduce((sum, score) => sum + score, 0) / scores.length)
191
+ const weightedOverall = computeWeightedOverall(dimensions, options.weights)
192
+ return { dimensions, overall, weightedOverall }
193
+ }
194
+
195
+ /** Weighted mean over determinable dimensions (weights default to 1; validated non-negative by config). */
196
+ function computeWeightedOverall(
197
+ dimensions: readonly ScorecardDimension[],
198
+ weights: Readonly<Record<ScorecardDimensionName, number>> | undefined,
199
+ ): number | null {
200
+ let weightedSum = 0
201
+ let weightTotal = 0
202
+ for (const entry of dimensions) {
203
+ if (entry.score === null) continue
204
+ const weight = weights?.[entry.name] ?? 1
205
+ weightedSum += weight * entry.score
206
+ weightTotal += weight
207
+ }
208
+ return weightTotal === 0 ? null : round6(weightedSum / weightTotal)
209
+ }
210
+
211
+ /**
212
+ * Limited `accuracy` definition: agreement between the full-table inferred
213
+ * type of each declared column and the declared type. Without a declared
214
+ * schema (or an external truth source) the dimension is `null` (undetermined)
215
+ * — accuracy is never fabricated.
216
+ */
217
+ function computeAccuracy(
218
+ stats: ReadonlyMap<string, ColumnStats>,
219
+ table: Table,
220
+ declaredSchema: Readonly<Record<string, InferredType>> | undefined,
221
+ ): ScorecardDimension {
222
+ if (declaredSchema === undefined || Object.keys(declaredSchema).length === 0) {
223
+ return { name: 'accuracy', score: null, note: 'undetermined: no declared schema or external truth to compare against; accuracy is never fabricated' }
224
+ }
225
+ let checked = 0
226
+ let matched = 0
227
+ const mismatches: string[] = []
228
+ for (const column of table.columns) {
229
+ const declared = declaredSchema[column]
230
+ if (declared === undefined) continue
231
+ checked += 1
232
+ const inferred = inferType(stats.get(column) as ColumnStats)
233
+ if (inferred === declared) matched += 1
234
+ else mismatches.push(`${column}: declared ${declared}, inferred ${inferred}`)
235
+ }
236
+ if (checked === 0) {
237
+ return { name: 'accuracy', score: null, note: 'undetermined: declared schema covers none of the dataset columns' }
238
+ }
239
+ return {
240
+ name: 'accuracy',
241
+ score: round6(matched / checked),
242
+ note: `${matched}/${checked} columns match their declared type` + (mismatches.length > 0 ? `; mismatches: ${mismatches.join(', ')}` : ''),
243
+ }
244
+ }
package/src/service.ts CHANGED
@@ -11,8 +11,10 @@
11
11
  import { Service, type Context } from '@deepseek-ai/cordis'
12
12
  import type { JsonValue, Session } from '@deepseek-ai/dsh-session'
13
13
  import type { CleanRule, CleanRuleLog } from './clean.ts'
14
+ import type { CleanContractSummary, CleanProfileDiff } from './contract.ts'
14
15
  import type { ProfileReport } from './profile.ts'
15
- import type { VerifyReport, VerifyRule } from './verify.ts'
16
+ import type { VerifyExpectation, VerifyReport, VerifyRule } from './verify.ts'
17
+ import type { ReportRecord, StoredReport } from './store.ts'
16
18
 
17
19
  export interface CitationCheckRequest {
18
20
  /** Workspace-relative path of the source dataset snapshot (CSV/JSON). */
@@ -46,6 +48,8 @@ export interface ProfileRequest {
46
48
  readonly dataset: string
47
49
  /** Optional deterministic systematic sample size for column cards. */
48
50
  readonly sample?: number | undefined
51
+ /** Optional industry preset id; its expected columns feed the scorecard's declared schema. */
52
+ readonly industryPreset?: string | undefined
49
53
  /** Absolute workspace root the dataset resolves inside. */
50
54
  readonly workspace: string
51
55
  /** Calling session (receives the `data-quality/profile` event), when any. */
@@ -62,6 +66,8 @@ export interface CleanRequest {
62
66
  readonly rules: readonly CleanRule[]
63
67
  /** Workspace-relative output path; omitted = no disk write, preview only. */
64
68
  readonly outputPath?: string | undefined
69
+ /** Dry run: no file written, no report persisted; returns the plan + expected contract/diff preview. */
70
+ readonly dryRun?: boolean | undefined
65
71
  /** Absolute workspace root the dataset resolves inside. */
66
72
  readonly workspace: string
67
73
  /** Calling session (receives the `data-quality/clean` event), when any. */
@@ -76,6 +82,8 @@ export interface VerifyRequest {
76
82
  readonly dataset: string
77
83
  /** Non-empty declarative verification rule list. */
78
84
  readonly rules: readonly VerifyRule[]
85
+ /** Optional metric expectations to reconcile (rowCount/columnSum/columnMean/uniqueCount/nullCount). */
86
+ readonly expectations?: readonly VerifyExpectation[] | undefined
79
87
  /** Absolute workspace root the dataset resolves inside. */
80
88
  readonly workspace: string
81
89
  /** Calling session (receives the `data-quality/verify` event), when any. */
@@ -89,9 +97,15 @@ export interface CleanRunReport {
89
97
  readonly dataset: string
90
98
  readonly inputRows: number
91
99
  readonly outputRows: number
100
+ /** Whether this was a dry run (no file written, no report persisted). */
101
+ readonly dryRun: boolean
92
102
  readonly logs: CleanRuleLog[]
103
+ /** Pre-delivery contract validation summary (dedupe/uniqueness/non-null/type/decision regressions). */
104
+ readonly contract: CleanContractSummary
93
105
  /** First `evidenceRowLimit` cleaned rows for inspection (display-truncated). */
94
106
  readonly preview: { readonly columns: string[]; readonly rows: Array<Record<string, JsonValue>> }
107
+ /** Expected before/after profile diff preview; present only for dry runs. */
108
+ readonly diffPreview?: CleanProfileDiff
95
109
  /** Workspace-relative output path when the run wrote a file. */
96
110
  readonly outputPath?: string
97
111
  /** Storage-domain key of the persisted report, when persistence is on. */
@@ -157,6 +171,22 @@ export abstract class DataQualityService extends Service {
157
171
  * @returns the verify report.
158
172
  */
159
173
  abstract verifyDataset(request: VerifyRequest): Promise<VerifyReport>
174
+
175
+ /**
176
+ * Read one persisted report by its storage key. The key is validated
177
+ * against the deterministic report-key format (path-safe) and a missing
178
+ * record fails loud.
179
+ * @param key - the storage report key.
180
+ * @returns the stored report (with its key).
181
+ */
182
+ abstract getReport(key: string): Promise<StoredReport>
183
+
184
+ /**
185
+ * List every persisted report of one kind, ordered by key (chronological).
186
+ * @param kind - the report kind.
187
+ * @returns the stored reports (empty when none match).
188
+ */
189
+ abstract listReports(kind: ReportRecord['kind']): Promise<StoredReport[]>
160
190
  }
161
191
 
162
192
  declare module '@deepseek-ai/cordis' {
@@ -168,5 +198,7 @@ declare module '@deepseek-ai/cordis' {
168
198
 
169
199
  /** Re-exports so consumers pull the whole seam vocabulary from one module. */
170
200
  export type { CleanResult, CleanRule, CleanRuleLog } from './clean.ts'
201
+ export type { CleanContractSummary, CleanProfileDiff } from './contract.ts'
171
202
  export type { ProfileReport } from './profile.ts'
172
- export type { VerifyReport, VerifyRule } from './verify.ts'
203
+ export type { VerifyReport, VerifyRule, VerifyExpectation, VerifyExpectationResult, VerifyMetric } from './verify.ts'
204
+ export type { ReportRecord, StoredReport } from './store.ts'
package/src/store.ts CHANGED
@@ -12,7 +12,7 @@ import { pathFingerprint } from './dataset.ts'
12
12
 
13
13
  /** Zod schema of one persisted report record (durable-boundary validation). */
14
14
  export const reportRecordSchema = z.object({
15
- kind: z.enum(['profile', 'clean', 'verify', 'citations']),
15
+ kind: z.enum(['profile', 'clean', 'verify', 'citations', 'clean-diff']),
16
16
  at: z.number().int().nonnegative(),
17
17
  dataset: z.string(),
18
18
  report: z.record(z.string(), z.unknown()),
@@ -20,7 +20,7 @@ export const reportRecordSchema = z.object({
20
20
 
21
21
  /** One persisted report record. */
22
22
  export interface ReportRecord {
23
- readonly kind: 'profile' | 'clean' | 'verify' | 'citations'
23
+ readonly kind: 'profile' | 'clean' | 'verify' | 'citations' | 'clean-diff'
24
24
  /** Injected run timestamp (epoch ms). */
25
25
  readonly at: number
26
26
  /** Workspace-relative dataset path as the caller gave it. */
@@ -29,6 +29,12 @@ export interface ReportRecord {
29
29
  readonly report: Record<string, unknown>
30
30
  }
31
31
 
32
+ /** A persisted report plus its storage key (the query surface's return value). */
33
+ export interface StoredReport extends ReportRecord {
34
+ /** The storage key the record was written under. */
35
+ readonly key: string
36
+ }
37
+
32
38
  /** The `dsh-data-quality` storage-domain declaration. */
33
39
  export const dataQualityDomainSpec = defineDomain({
34
40
  name: 'data_quality',
@@ -52,6 +58,26 @@ export interface ReportStore {
52
58
  * @returns the record, or `undefined` when absent.
53
59
  */
54
60
  get(key: string): ReportRecord | undefined
61
+ /**
62
+ * Snapshot of every record of one kind, ordered by key (chronological).
63
+ * @param kind - the report kind.
64
+ * @returns the stored reports (empty when none match).
65
+ */
66
+ list(kind: ReportRecord['kind']): StoredReport[]
67
+ }
68
+
69
+ /** Well-formed report-key shape: `<17-digit timestamp>-<kind>-<8-hex fingerprint>`. */
70
+ const REPORT_KEY_PATTERN = /^\d{17}-(?:profile|clean|verify|citations|clean-diff)-[0-9a-f]{8}$/u
71
+
72
+ /**
73
+ * Whether `key` is a well-formed, path-safe storage report key. Rejects any
74
+ * key with separators, traversal, or unexpected characters before it can be
75
+ * handed to the storage backend.
76
+ * @param key - candidate report key.
77
+ * @returns whether the key matches the deterministic report-key format.
78
+ */
79
+ export function isValidReportKey(key: string): boolean {
80
+ return REPORT_KEY_PATTERN.test(key)
55
81
  }
56
82
 
57
83
  /** Pad to two digits for the key timestamp. */
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools'
9
9
  import type { CleanRule, CleanRunReport, DataQualityService } from '../service.ts'
10
+ import { PROFILE_REPORT_SCHEMA } from './profile-report-schema.ts'
10
11
  import { renderCleanText, workspaceOf } from './shared.ts'
11
12
 
12
13
  const STRING_MAP_SCHEMA = { type: 'json' } as const
@@ -91,12 +92,13 @@ export function defineCleanTool(service: DataQualityService) {
91
92
  description: [
92
93
  'Apply declarative cleaning rules to a workspace CSV/TSV/JSON/JSONL dataset with deterministic TypeScript computation (no mental math).',
93
94
  'Rules apply in array order: dedupe (by column group), fill-missing (constant/mean/median/forward), coerce-type (number/date/boolean; failures counted and set to missing), normalize-unit (e.g. 万/亿 suffixes to base units), trim (whitespace), map-values (enum mapping).',
94
- 'The source file is NEVER overwritten. Without outputPath the run is preview-only; with outputPath the cleaned dataset is written there (workspace-confined, .csv/.tsv/.json/.jsonl). Returns the per-rule audit log (affected rows per rule) plus a bounded preview. The full report persists to the data_quality storage domain (reportKey).',
95
+ 'The source file is NEVER overwritten. Without outputPath the run is preview-only; with outputPath the cleaned dataset is written there (workspace-confined, .csv/.tsv/.json/.jsonl). Returns the per-rule audit log, the pre-delivery contract summary (with per-column decision trace), and a bounded preview. Pass dryRun: true to skip the write and get the cleaning plan plus the expected contract/diff preview instead. The full report persists to the data_quality storage domain (reportKey).',
95
96
  ].join('\n'),
96
97
  parameters: {
97
98
  path: { type: 'string', required: true, description: 'Workspace-relative dataset path (.csv/.tsv/.json/.jsonl).' },
98
99
  rules: { ...CLEAN_RULE_SCHEMA, required: true },
99
100
  outputPath: { type: 'string', description: 'Optional workspace-relative output path for the cleaned dataset (must differ from path).' },
101
+ dryRun: { type: 'boolean', description: 'When true, do not write any output file; return the cleaning plan and expected contract/diff preview instead (default false).' },
100
102
  },
101
103
  output: {
102
104
  schema: {
@@ -105,9 +107,20 @@ export function defineCleanTool(service: DataQualityService) {
105
107
  dataset: { type: 'string', required: true },
106
108
  inputRows: { type: 'number', required: true },
107
109
  outputRows: { type: 'number', required: true },
110
+ dryRun: { type: 'boolean', required: true },
108
111
  generatedAt: { type: 'number', required: true },
109
112
  outputPath: { type: 'string' },
110
113
  reportKey: { type: 'string' },
114
+ diffPreview: {
115
+ type: 'object',
116
+ properties: {
117
+ dataset: { type: 'string', required: true },
118
+ before: { ...PROFILE_REPORT_SCHEMA, required: true },
119
+ after: { ...PROFILE_REPORT_SCHEMA, required: true },
120
+ generatedAt: { type: 'number', required: true },
121
+ },
122
+ additionalProperties: false,
123
+ },
111
124
  logs: {
112
125
  type: 'array',
113
126
  items: {
@@ -122,6 +135,67 @@ export function defineCleanTool(service: DataQualityService) {
122
135
  },
123
136
  required: true,
124
137
  },
138
+ contract: {
139
+ type: 'object',
140
+ properties: {
141
+ inputRows: { type: 'number', required: true },
142
+ outputRows: { type: 'number', required: true },
143
+ removedRows: { type: 'number', required: true },
144
+ dedupeColumns: { oneOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }], required: true },
145
+ uniqueKeys: { type: 'boolean', required: true },
146
+ remainingDuplicateRows: { type: 'number', required: true },
147
+ remainingMissing: {
148
+ type: 'array',
149
+ items: {
150
+ type: 'object',
151
+ properties: {
152
+ column: { type: 'string', required: true },
153
+ count: { type: 'number', required: true },
154
+ },
155
+ additionalProperties: false,
156
+ },
157
+ required: true,
158
+ },
159
+ typeConformance: {
160
+ type: 'array',
161
+ items: {
162
+ type: 'object',
163
+ properties: {
164
+ column: { type: 'string', required: true },
165
+ to: { type: 'string', enum: ['number', 'date', 'boolean'], required: true },
166
+ invalidCount: { type: 'number', required: true },
167
+ },
168
+ additionalProperties: false,
169
+ },
170
+ required: true,
171
+ },
172
+ columnDecisions: {
173
+ type: 'array',
174
+ items: {
175
+ type: 'object',
176
+ properties: {
177
+ column: { type: 'string', required: true },
178
+ decisions: {
179
+ type: 'array',
180
+ items: {
181
+ type: 'object',
182
+ properties: {
183
+ strategy: { type: 'string', required: true },
184
+ affectedRows: { type: 'number', required: true },
185
+ },
186
+ additionalProperties: false,
187
+ },
188
+ required: true,
189
+ },
190
+ },
191
+ additionalProperties: false,
192
+ },
193
+ required: true,
194
+ },
195
+ },
196
+ additionalProperties: false,
197
+ required: true,
198
+ },
125
199
  preview: {
126
200
  type: 'object',
127
201
  properties: {
@@ -141,6 +215,7 @@ export function defineCleanTool(service: DataQualityService) {
141
215
  dataset: args.path,
142
216
  rules: args.rules as unknown as readonly CleanRule[],
143
217
  ...(args.outputPath !== undefined ? { outputPath: args.outputPath } : {}),
218
+ ...(args.dryRun !== undefined ? { dryRun: args.dryRun } : {}),
144
219
  workspace: workspaceOf(exec),
145
220
  session: exec.agent?.session,
146
221
  signal: exec.signal,