dsh-data-quality 0.1.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 (91) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +181 -0
  4. package/README.hi.md +181 -0
  5. package/README.md +181 -0
  6. package/README.pt.md +181 -0
  7. package/README.zh.md +181 -0
  8. package/THIRD_PARTY_NOTICES.md +20 -0
  9. package/cordis.patch.yml +46 -0
  10. package/lib/index.js +2458 -0
  11. package/lib/types/clean.d.ts +82 -0
  12. package/lib/types/clean.d.ts.map +1 -0
  13. package/lib/types/clean.js +351 -0
  14. package/lib/types/clean.js.map +1 -0
  15. package/lib/types/config.d.ts +47 -0
  16. package/lib/types/config.d.ts.map +1 -0
  17. package/lib/types/config.js +66 -0
  18. package/lib/types/config.js.map +1 -0
  19. package/lib/types/dataset.d.ts +133 -0
  20. package/lib/types/dataset.d.ts.map +1 -0
  21. package/lib/types/dataset.js +404 -0
  22. package/lib/types/dataset.js.map +1 -0
  23. package/lib/types/events.d.ts +73 -0
  24. package/lib/types/events.d.ts.map +1 -0
  25. package/lib/types/events.js +41 -0
  26. package/lib/types/events.js.map +1 -0
  27. package/lib/types/index.d.ts +45 -0
  28. package/lib/types/index.d.ts.map +1 -0
  29. package/lib/types/index.js +78 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/lib/types/present.d.ts +24 -0
  32. package/lib/types/present.d.ts.map +1 -0
  33. package/lib/types/present.js +34 -0
  34. package/lib/types/present.js.map +1 -0
  35. package/lib/types/profile.d.ts +79 -0
  36. package/lib/types/profile.d.ts.map +1 -0
  37. package/lib/types/profile.js +196 -0
  38. package/lib/types/profile.js.map +1 -0
  39. package/lib/types/provider-local.d.ts +56 -0
  40. package/lib/types/provider-local.d.ts.map +1 -0
  41. package/lib/types/provider-local.js +163 -0
  42. package/lib/types/provider-local.js.map +1 -0
  43. package/lib/types/service.d.ts +160 -0
  44. package/lib/types/service.d.ts.map +1 -0
  45. package/lib/types/service.js +25 -0
  46. package/lib/types/service.js.map +1 -0
  47. package/lib/types/store.d.ts +61 -0
  48. package/lib/types/store.d.ts.map +1 -0
  49. package/lib/types/store.js +42 -0
  50. package/lib/types/store.js.map +1 -0
  51. package/lib/types/tools/clean.d.ts +14 -0
  52. package/lib/types/tools/clean.d.ts.map +1 -0
  53. package/lib/types/tools/clean.js +146 -0
  54. package/lib/types/tools/clean.js.map +1 -0
  55. package/lib/types/tools/profile.d.ts +13 -0
  56. package/lib/types/tools/profile.d.ts.map +1 -0
  57. package/lib/types/tools/profile.js +91 -0
  58. package/lib/types/tools/profile.js.map +1 -0
  59. package/lib/types/tools/shared.d.ts +19 -0
  60. package/lib/types/tools/shared.d.ts.map +1 -0
  61. package/lib/types/tools/shared.js +44 -0
  62. package/lib/types/tools/shared.js.map +1 -0
  63. package/lib/types/tools/verify.d.ts +14 -0
  64. package/lib/types/tools/verify.d.ts.map +1 -0
  65. package/lib/types/tools/verify.js +160 -0
  66. package/lib/types/tools/verify.js.map +1 -0
  67. package/lib/types/verify.d.ts +124 -0
  68. package/lib/types/verify.d.ts.map +1 -0
  69. package/lib/types/verify.js +391 -0
  70. package/lib/types/verify.js.map +1 -0
  71. package/lib/types/version.d.ts +8 -0
  72. package/lib/types/version.d.ts.map +1 -0
  73. package/lib/types/version.js +8 -0
  74. package/lib/types/version.js.map +1 -0
  75. package/package.json +137 -0
  76. package/src/clean.ts +382 -0
  77. package/src/config.ts +104 -0
  78. package/src/dataset.ts +445 -0
  79. package/src/events.ts +90 -0
  80. package/src/index.ts +115 -0
  81. package/src/present.ts +38 -0
  82. package/src/profile.ts +250 -0
  83. package/src/provider-local.ts +194 -0
  84. package/src/service.ts +172 -0
  85. package/src/store.ts +74 -0
  86. package/src/tools/clean.ts +150 -0
  87. package/src/tools/profile.ts +94 -0
  88. package/src/tools/shared.ts +47 -0
  89. package/src/tools/verify.ts +163 -0
  90. package/src/verify.ts +496 -0
  91. package/src/version.ts +8 -0
package/package.json ADDED
@@ -0,0 +1,137 @@
1
+ {
2
+ "name": "dsh-data-quality",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic data profiling, cleaning, and verification for DeepSeek Harness: a ctx.dataQuality capability seam (Service Definition / local Provider / tool Consumers) with data_profile, data_clean, and data_verify model tools, a frozen cross-plugin verifyCitations contract, durable reports in a storage domain, and data-quality/* session events on hosts that support them.",
5
+ "author": "dsh-data-quality contributors",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./lib/index.js",
9
+ "types": "./lib/types/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./lib/types/index.d.ts",
13
+ "default": "./lib/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "src",
20
+ "cordis.patch.yml",
21
+ "CHANGELOG.md",
22
+ "THIRD_PARTY_NOTICES.md",
23
+ "README.md",
24
+ "README.zh.md",
25
+ "README.es.md",
26
+ "README.pt.md",
27
+ "README.hi.md",
28
+ "LICENSE"
29
+ ],
30
+ "sideEffects": false,
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ }
35
+ },
36
+ "dshWorkshop": {
37
+ "schema": "omdsh-workshop-package/v1",
38
+ "type": "plugin",
39
+ "integration": {
40
+ "protocol": "harness-profile",
41
+ "artifact": "cordis.patch.yml"
42
+ },
43
+ "install": {
44
+ "mode": "transactional",
45
+ "adapter": "profile-bundle",
46
+ "failurePolicy": "generation-rollback",
47
+ "touchesCurrentBeforeActivation": false
48
+ },
49
+ "lifecycle": {
50
+ "activation": "restart-profile",
51
+ "dispose": "supported"
52
+ },
53
+ "permissions": [
54
+ "filesystem:read",
55
+ "filesystem:write",
56
+ "session:read",
57
+ "session:write",
58
+ "native-code:none"
59
+ ],
60
+ "compatibility": {
61
+ "dshVersions": [
62
+ "0.1.0-rc.6"
63
+ ]
64
+ },
65
+ "capability": {
66
+ "id": "data-quality",
67
+ "kind": "data-tooling",
68
+ "invocation": "mount the dsh-data-quality bundle row",
69
+ "expected": "provides ctx.dataQuality (verifyCitations + profile/clean/verify service methods), registers the data_profile / data_clean / data_verify tools, and persists reports to the data_quality storage domain"
70
+ },
71
+ "evidence": {
72
+ "install": null,
73
+ "failureIsolation": null,
74
+ "hotReload": null,
75
+ "remove": null
76
+ }
77
+ },
78
+ "keywords": [
79
+ "dsh",
80
+ "dsh-plugin",
81
+ "deepseek-harness",
82
+ "cordis",
83
+ "data-quality",
84
+ "data-cleaning",
85
+ "data-profiling",
86
+ "data-verification"
87
+ ],
88
+ "engines": {
89
+ "node": "^22.19.0 || >=24.0.0"
90
+ },
91
+ "packageManager": "pnpm@11.7.0",
92
+ "peerDependencies": {
93
+ "@deepseek-ai/cordis": "^4.0.1",
94
+ "@deepseek-ai/dsh-session": "0.1.0-rc.6",
95
+ "@deepseek-ai/dsh-storage": "0.1.0-rc.6",
96
+ "@deepseek-ai/dsh-storage-domain": "0.1.0-rc.6",
97
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
98
+ "@deepseek-ai/schemastery": "^3.18.0"
99
+ },
100
+ "dependencies": {
101
+ "tsdown": "^0.22.14",
102
+ "typescript": "^5.9.0",
103
+ "zod": "^4.4.3"
104
+ },
105
+ "devDependencies": {
106
+ "@deepseek-ai/cordis": "^4.0.1",
107
+ "@deepseek-ai/cordis-plugin-include": "1.0.6",
108
+ "@deepseek-ai/cordis-plugin-loader": "1.0.2",
109
+ "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
110
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
111
+ "@deepseek-ai/dsh-session": "0.1.0-rc.6",
112
+ "@deepseek-ai/dsh-storage": "0.1.0-rc.6",
113
+ "@deepseek-ai/dsh-storage-domain": "0.1.0-rc.6",
114
+ "@deepseek-ai/dsh-storage-json": "0.1.0-rc.6",
115
+ "@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
116
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
117
+ "@deepseek-ai/schemastery": "^3.18.0",
118
+ "@types/node": "^22.19.0",
119
+ "@vitest/coverage-v8": "^3.2.0",
120
+ "oxlint": "0.18.1",
121
+ "vitest": "^3.2.0"
122
+ },
123
+ "scripts": {
124
+ "build": "node scripts/prepare.mjs",
125
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
126
+ "typecheck:ci": "tsc -p tsconfig.ci.json --noEmit",
127
+ "test": "vitest run",
128
+ "test:coverage": "vitest run --coverage",
129
+ "lint": "oxlint",
130
+ "prepare": "node scripts/prepare.mjs",
131
+ "verify:self-contained": "node scripts/verify-self-contained.mjs",
132
+ "verify:artifacts": "node scripts/verify-artifacts.mjs",
133
+ "verify:readme-sync": "node scripts/check-readme-sync.mjs",
134
+ "pack:check": "node scripts/prepare.mjs && pnpm pack"
135
+ },
136
+ "license": "Apache-2.0"
137
+ }
package/src/clean.ts ADDED
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Declarative cleaning rules over a parsed {@link Table}: dedupe, fill-missing,
3
+ * coerce-type, normalize-unit, trim, and map-values. Pure and deterministic —
4
+ * rules apply in array order, each seeing the previous rule's output; every
5
+ * rule logs how many rows it affected. Unknown columns and invalid rule
6
+ * parameters fail loud.
7
+ * @module dsh-data-quality/clean
8
+ */
9
+
10
+ import { isMissing, parseBoolean, parseDate, parseNumeric, throwIfAborted, type Cell, type Row, type Table } from './dataset.ts'
11
+
12
+ /** One declarative cleaning rule (discriminated on `rule`). */
13
+ export type CleanRule =
14
+ | { readonly rule: 'dedupe'; readonly columns?: readonly string[] }
15
+ | {
16
+ readonly rule: 'fill-missing'
17
+ readonly column: string
18
+ readonly strategy: 'constant' | 'mean' | 'median' | 'forward'
19
+ readonly value?: string | number
20
+ }
21
+ | { readonly rule: 'coerce-type'; readonly column: string; readonly to: 'number' | 'date' | 'boolean' }
22
+ | { readonly rule: 'normalize-unit'; readonly column: string; readonly factors: Readonly<Record<string, number>> }
23
+ | { readonly rule: 'trim'; readonly columns?: readonly string[] }
24
+ | {
25
+ readonly rule: 'map-values'
26
+ readonly column: string
27
+ readonly map: Readonly<Record<string, string>>
28
+ readonly else?: 'keep' | 'missing'
29
+ }
30
+
31
+ /** Raised for invalid rules or unknown columns; `message` names the rule index and reason. */
32
+ export class CleanRuleError extends Error {
33
+ /**
34
+ * @param ruleIndex - index of the offending rule in the request array.
35
+ * @param message - actionable human-readable detail.
36
+ */
37
+ constructor(
38
+ readonly ruleIndex: number,
39
+ message: string,
40
+ ) {
41
+ super(message)
42
+ this.name = 'CleanRuleError'
43
+ }
44
+ }
45
+
46
+ /** Per-rule audit entry. */
47
+ export interface CleanRuleLog {
48
+ readonly ruleIndex: number
49
+ readonly rule: CleanRule['rule']
50
+ /** Rows the rule changed (removed, filled, coerced, rewritten, or trimmed). */
51
+ readonly affectedRows: number
52
+ /** Extra detail, e.g. coerce failure counts. */
53
+ readonly detail: string
54
+ }
55
+
56
+ /** The cleaning outcome: cleaned table plus the per-rule audit log. */
57
+ export interface CleanResult {
58
+ readonly columns: string[]
59
+ readonly rows: Row[]
60
+ readonly logs: CleanRuleLog[]
61
+ readonly inputRows: number
62
+ readonly outputRows: number
63
+ }
64
+
65
+ /** Assert `column` exists; every rule validates its columns before touching data. */
66
+ function requireColumn(columns: readonly string[], column: string, ruleIndex: number): void {
67
+ if (!columns.includes(column)) {
68
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: unknown column ${JSON.stringify(column)} (columns: ${columns.join(', ')})`)
69
+ }
70
+ }
71
+
72
+ /** Assert every selected column exists. */
73
+ function requireColumns(columns: readonly string[], selected: readonly string[], ruleIndex: number): void {
74
+ for (const column of selected) requireColumn(columns, column, ruleIndex)
75
+ }
76
+
77
+ /** dedupe: keep the first row of each key group; later duplicates are removed. */
78
+ function applyDedupe(table: Table, rule: Extract<CleanRule, { rule: 'dedupe' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
79
+ const selected = rule.columns ?? table.columns
80
+ requireColumns(table.columns, selected, ruleIndex)
81
+ const seen = new Set<string>()
82
+ const rows: Row[] = []
83
+ let removed = 0
84
+ for (const [index, row] of table.rows.entries()) {
85
+ if (index % 1024 === 0) throwIfAborted(signal)
86
+ const key = JSON.stringify(selected.map((column) => row[column] ?? null))
87
+ if (seen.has(key)) {
88
+ removed += 1
89
+ continue
90
+ }
91
+ seen.add(key)
92
+ rows.push(row)
93
+ }
94
+ return {
95
+ table: { columns: table.columns, rows },
96
+ log: { ruleIndex, rule: 'dedupe', affectedRows: removed, detail: `removed ${removed} duplicate row(s) by [${selected.join(', ')}]` },
97
+ }
98
+ }
99
+
100
+ /** fill-missing: constant / mean / median / forward fill of missing cells in one column. */
101
+ function applyFillMissing(table: Table, rule: Extract<CleanRule, { rule: 'fill-missing' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
102
+ requireColumn(table.columns, rule.column, ruleIndex)
103
+ let fill: Cell | undefined
104
+ let detail: string
105
+ switch (rule.strategy) {
106
+ case 'constant': {
107
+ if (rule.value === undefined) {
108
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: fill-missing strategy "constant" requires a value`)
109
+ }
110
+ fill = rule.value
111
+ detail = `filled with constant ${JSON.stringify(rule.value)}`
112
+ break
113
+ }
114
+ case 'mean':
115
+ case 'median': {
116
+ const values = table.rows.map((row) => parseNumeric(row[rule.column])).filter((value) => value !== undefined).sort((a, b) => a - b)
117
+ if (values.length === 0) {
118
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: fill-missing strategy "${rule.strategy}" needs at least one numeric value in column ${JSON.stringify(rule.column)}`)
119
+ }
120
+ const computed = rule.strategy === 'mean'
121
+ ? values.reduce((acc, value) => acc + value, 0) / values.length
122
+ : values.length % 2 === 1
123
+ ? (values[(values.length - 1) / 2] as number)
124
+ : ((values[values.length / 2 - 1] as number) + (values[values.length / 2] as number)) / 2
125
+ fill = computed
126
+ detail = `filled with ${rule.strategy} ${computed}`
127
+ break
128
+ }
129
+ case 'forward': {
130
+ fill = undefined
131
+ detail = 'filled with the previous non-missing value'
132
+ break
133
+ }
134
+ }
135
+ let filled = 0
136
+ let previous: Cell | undefined
137
+ const rows = table.rows.map((row, index) => {
138
+ if (index % 1024 === 0) throwIfAborted(signal)
139
+ const cell = row[rule.column]
140
+ if (!isMissing(cell)) {
141
+ previous = cell ?? null
142
+ return row
143
+ }
144
+ const replacement = rule.strategy === 'forward' ? previous : fill
145
+ if (replacement === undefined) return row
146
+ filled += 1
147
+ return { ...row, [rule.column]: replacement }
148
+ })
149
+ return {
150
+ table: { columns: table.columns, rows },
151
+ log: { ruleIndex, rule: 'fill-missing', affectedRows: filled, detail: `${detail}; ${filled} row(s) filled` },
152
+ }
153
+ }
154
+
155
+ /** coerce-type: rewrite one column to number / ISO date / boolean; failures become missing and are counted. */
156
+ function applyCoerceType(table: Table, rule: Extract<CleanRule, { rule: 'coerce-type' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
157
+ requireColumn(table.columns, rule.column, ruleIndex)
158
+ let coerced = 0
159
+ let failed = 0
160
+ const failureExamples: string[] = []
161
+ const rows = table.rows.map((row, index) => {
162
+ if (index % 1024 === 0) throwIfAborted(signal)
163
+ const cell = row[rule.column]
164
+ if (isMissing(cell)) return row
165
+ let value: Cell | undefined
166
+ switch (rule.to) {
167
+ case 'number': {
168
+ const numeric = parseNumeric(cell)
169
+ value = numeric === undefined ? undefined : numeric
170
+ break
171
+ }
172
+ case 'date': {
173
+ const epoch = parseDate(cell)
174
+ if (epoch === undefined) {
175
+ value = undefined
176
+ } else {
177
+ const date = new Date(epoch)
178
+ value = date.getUTCHours() === 0 && date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0
179
+ ? date.toISOString().slice(0, 10)
180
+ : date.toISOString()
181
+ }
182
+ break
183
+ }
184
+ case 'boolean': {
185
+ const bool = parseBoolean(cell)
186
+ value = bool === undefined ? undefined : String(bool)
187
+ break
188
+ }
189
+ }
190
+ if (value === undefined) {
191
+ failed += 1
192
+ if (failureExamples.length < 3) failureExamples.push(JSON.stringify(cell))
193
+ return { ...row, [rule.column]: null }
194
+ }
195
+ coerced += 1
196
+ return { ...row, [rule.column]: value }
197
+ })
198
+ const detail = `${coerced} value(s) coerced to ${rule.to}` +
199
+ (failed > 0 ? `; ${failed} failure(s) set to missing (e.g. ${failureExamples.join(', ')})` : '')
200
+ return {
201
+ table: { columns: table.columns, rows },
202
+ log: { ruleIndex, rule: 'coerce-type', affectedRows: coerced, detail },
203
+ }
204
+ }
205
+
206
+ /** normalize-unit: strip a unit suffix and multiply by its factor (e.g. 万 -> 1e4). */
207
+ function applyNormalizeUnit(table: Table, rule: Extract<CleanRule, { rule: 'normalize-unit' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
208
+ requireColumn(table.columns, rule.column, ruleIndex)
209
+ const entries = Object.entries(rule.factors)
210
+ if (entries.length === 0) {
211
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: normalize-unit requires a non-empty factors map`)
212
+ }
213
+ for (const [suffix, factor] of entries) {
214
+ if (suffix === '') throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: normalize-unit suffixes must not be empty`)
215
+ if (!Number.isFinite(factor) || factor <= 0) {
216
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: factor for ${JSON.stringify(suffix)} must be a positive finite number, got ${String(factor)}`)
217
+ }
218
+ }
219
+ // Longest suffix first so "亿万" style prefixes cannot shadow longer units.
220
+ const ordered = [...entries].sort((a, b) => b[0].length - a[0].length)
221
+ let normalized = 0
222
+ let skipped = 0
223
+ const rows = table.rows.map((row, index) => {
224
+ if (index % 1024 === 0) throwIfAborted(signal)
225
+ const cell = row[rule.column]
226
+ if (isMissing(cell)) return row
227
+ const plain = parseNumeric(cell)
228
+ if (plain !== undefined) {
229
+ normalized += 1
230
+ return { ...row, [rule.column]: plain }
231
+ }
232
+ if (typeof cell === 'string') {
233
+ const text = cell.trim()
234
+ for (const [suffix, factor] of ordered) {
235
+ if (!text.endsWith(suffix)) continue
236
+ const numeric = parseNumeric(text.slice(0, text.length - suffix.length))
237
+ if (numeric === undefined) continue
238
+ normalized += 1
239
+ return { ...row, [rule.column]: numeric * factor }
240
+ }
241
+ }
242
+ skipped += 1
243
+ return row
244
+ })
245
+ const detail = `${normalized} value(s) normalized to base units` + (skipped > 0 ? `; ${skipped} unparseable value(s) left unchanged` : '')
246
+ return {
247
+ table: { columns: table.columns, rows },
248
+ log: { ruleIndex, rule: 'normalize-unit', affectedRows: normalized, detail },
249
+ }
250
+ }
251
+
252
+ /** trim: strip leading/trailing whitespace of string cells in the selected columns. */
253
+ function applyTrim(table: Table, rule: Extract<CleanRule, { rule: 'trim' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
254
+ const selected = rule.columns ?? table.columns
255
+ requireColumns(table.columns, selected, ruleIndex)
256
+ let trimmed = 0
257
+ const rows = table.rows.map((row, index) => {
258
+ if (index % 1024 === 0) throwIfAborted(signal)
259
+ let changed = false
260
+ const next = { ...row }
261
+ for (const column of selected) {
262
+ const cell = next[column]
263
+ if (typeof cell !== 'string') continue
264
+ const value = cell.trim()
265
+ if (value !== cell) {
266
+ next[column] = value
267
+ changed = true
268
+ }
269
+ }
270
+ if (changed) trimmed += 1
271
+ return changed ? next : row
272
+ })
273
+ return {
274
+ table: { columns: table.columns, rows },
275
+ log: { ruleIndex, rule: 'trim', affectedRows: trimmed, detail: `trimmed whitespace in [${selected.join(', ')}] for ${trimmed} row(s)` },
276
+ }
277
+ }
278
+
279
+ /** map-values: exact-match enum mapping; unmapped values follow the `else` policy. */
280
+ function applyMapValues(table: Table, rule: Extract<CleanRule, { rule: 'map-values' }>, ruleIndex: number, signal?: AbortSignal): { table: Table; log: CleanRuleLog } {
281
+ requireColumn(table.columns, rule.column, ruleIndex)
282
+ const elsePolicy = rule.else ?? 'keep'
283
+ let mapped = 0
284
+ let unmapped = 0
285
+ const rows = table.rows.map((row, index) => {
286
+ if (index % 1024 === 0) throwIfAborted(signal)
287
+ const cell = row[rule.column]
288
+ if (isMissing(cell)) return row
289
+ const key = typeof cell === 'string' ? cell : typeof cell === 'object' && cell !== null ? JSON.stringify(cell) : String(cell)
290
+ const replacement = rule.map[key]
291
+ if (replacement !== undefined) {
292
+ mapped += 1
293
+ return { ...row, [rule.column]: replacement }
294
+ }
295
+ if (elsePolicy === 'missing') {
296
+ unmapped += 1
297
+ return { ...row, [rule.column]: null }
298
+ }
299
+ unmapped += 1
300
+ return row
301
+ })
302
+ const detail = `${mapped} value(s) mapped` + (unmapped > 0 ? `; ${unmapped} unmapped value(s) ${elsePolicy === 'missing' ? 'set to missing' : 'kept'}` : '')
303
+ return {
304
+ table: { columns: table.columns, rows },
305
+ log: { ruleIndex, rule: 'map-values', affectedRows: mapped, detail },
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Apply cleaning rules in array order; each rule sees the previous output.
311
+ * @param table - the parsed dataset.
312
+ * @param rules - non-empty rule list.
313
+ * @param options - abort signal.
314
+ * @returns the cleaned table plus the per-rule audit log.
315
+ */
316
+ export function applyCleanRules(table: Table, rules: readonly CleanRule[], options: { signal?: AbortSignal | undefined } = {}): CleanResult {
317
+ if (rules.length === 0) {
318
+ throw new CleanRuleError(0, 'rules must be a non-empty array')
319
+ }
320
+ let current: Table = { columns: [...table.columns], rows: table.rows.map((row) => ({ ...row })) }
321
+ const logs: CleanRuleLog[] = []
322
+ for (const [ruleIndex, rule] of rules.entries()) {
323
+ throwIfAborted(options.signal)
324
+ let step: { table: Table; log: CleanRuleLog }
325
+ switch (rule.rule) {
326
+ case 'dedupe':
327
+ step = applyDedupe(current, rule, ruleIndex, options.signal)
328
+ break
329
+ case 'fill-missing':
330
+ step = applyFillMissing(current, rule, ruleIndex, options.signal)
331
+ break
332
+ case 'coerce-type':
333
+ step = applyCoerceType(current, rule, ruleIndex, options.signal)
334
+ break
335
+ case 'normalize-unit':
336
+ step = applyNormalizeUnit(current, rule, ruleIndex, options.signal)
337
+ break
338
+ case 'trim':
339
+ step = applyTrim(current, rule, ruleIndex, options.signal)
340
+ break
341
+ case 'map-values':
342
+ step = applyMapValues(current, rule, ruleIndex, options.signal)
343
+ break
344
+ default:
345
+ throw new CleanRuleError(ruleIndex, `rule ${ruleIndex}: unknown rule type ${JSON.stringify((rule as CleanRule).rule)}`)
346
+ }
347
+ current = step.table
348
+ logs.push(step.log)
349
+ }
350
+ return {
351
+ columns: current.columns,
352
+ rows: current.rows,
353
+ logs,
354
+ inputRows: table.rows.length,
355
+ outputRows: current.rows.length,
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Serialize a table back to CSV/TSV text with RFC-4180 quoting (used when a
361
+ * clean run writes an output file). Numbers and booleans render via `String`;
362
+ * missing cells render empty.
363
+ * @param columns - column order.
364
+ * @param rows - rows to serialize.
365
+ * @param delimiter - field delimiter.
366
+ * @returns the file text (LF newlines, trailing newline).
367
+ */
368
+ export function serializeDelimited(columns: readonly string[], rows: readonly Row[], delimiter: string): string {
369
+ const encode = (cell: Cell | undefined): string => {
370
+ if (isMissing(cell)) return ''
371
+ const text = typeof cell === 'string' ? cell : typeof cell === 'object' && cell !== null ? JSON.stringify(cell) : String(cell)
372
+ if (text.includes(delimiter) || text.includes('"') || text.includes('\n') || text.includes('\r')) {
373
+ return `"${text.replace(/"/gu, '""')}"`
374
+ }
375
+ return text
376
+ }
377
+ const lines = [columns.map((column) => encode(column)).join(delimiter)]
378
+ for (const row of rows) {
379
+ lines.push(columns.map((column) => encode(row[column])).join(delimiter))
380
+ }
381
+ return `${lines.join('\n')}\n`
382
+ }
package/src/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Config schema and resolution for `dsh-data-quality`. Every tunable is a
3
+ * validated {@link Config} field changeable from cordis.yml; the resolution
4
+ * step validates bounds so misconfiguration fails loud at mount.
5
+ * @module dsh-data-quality/config
6
+ */
7
+
8
+ import z from '@deepseek-ai/schemastery'
9
+
10
+ /** Raw plugin config — every field optional; {@link resolveConfig} supplies the defaults. */
11
+ export interface Config {
12
+ /** Master switch; `false` mounts nothing. */
13
+ enabled?: boolean
14
+ /** Hard row cap per dataset load. */
15
+ maxRows?: number
16
+ /** Hard file-size cap in MiB per dataset load. */
17
+ maxFileSizeMB?: number
18
+ /** Default relative tolerance for numeric citation comparison. */
19
+ defaultTolerance?: number
20
+ /** Cap on evidence rows (verify) and preview rows (clean) in one result. */
21
+ evidenceRowLimit?: number
22
+ /** Extensions accepted as datasets (dot-prefixed, lowercase). */
23
+ allowedExtensions?: string[]
24
+ /** Absolute root for service-level calls without a session workspace; empty = process launch directory. */
25
+ workspaceRoot?: string
26
+ /** Persist reports to the `data_quality` storage domain. */
27
+ storeReports?: boolean
28
+ }
29
+
30
+ /** Fully resolved config handed to the runtime. */
31
+ export interface ResolvedConfig {
32
+ readonly enabled: boolean
33
+ readonly maxRows: number
34
+ readonly maxFileSizeMB: number
35
+ readonly defaultTolerance: number
36
+ readonly evidenceRowLimit: number
37
+ readonly allowedExtensions: readonly string[]
38
+ readonly workspaceRoot: string
39
+ readonly storeReports: boolean
40
+ }
41
+
42
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
43
+ export const Config: z<Config> = z.object({
44
+ enabled: z.boolean().default(true),
45
+ maxRows: z.number().default(200_000),
46
+ maxFileSizeMB: z.number().default(64),
47
+ defaultTolerance: z.number().default(1e-9),
48
+ evidenceRowLimit: z.number().default(20),
49
+ allowedExtensions: z.array(z.string()).default(['.csv', '.tsv', '.json', '.jsonl']),
50
+ workspaceRoot: z.string().default(''),
51
+ storeReports: z.boolean().default(true),
52
+ })
53
+
54
+ /** Throw unless `value` is a positive safe integer. */
55
+ function assertPositiveInt(name: string, value: number): void {
56
+ if (!Number.isSafeInteger(value) || value <= 0) {
57
+ throw new TypeError(`${name} must be a positive safe integer, got ${String(value)}`)
58
+ }
59
+ }
60
+
61
+ /** Throw unless `value` is a finite number in `[min, max]`. */
62
+ function assertFiniteRange(name: string, value: number, min: number, max: number): void {
63
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
64
+ throw new TypeError(`${name} must be a finite number in [${min}, ${max}], got ${String(value)}`)
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Validate raw values and fill explicit defaults. Invalid bounds throw here —
70
+ * misconfiguration fails loud at mount even without the Schemastery loader.
71
+ * @param config - raw (possibly partial) plugin config.
72
+ * @returns the fully resolved config.
73
+ */
74
+ export function resolveConfig(config: Config = {}): ResolvedConfig {
75
+ const maxRows = config.maxRows ?? 200_000
76
+ assertPositiveInt('maxRows', maxRows)
77
+ const maxFileSizeMB = config.maxFileSizeMB ?? 64
78
+ assertFiniteRange('maxFileSizeMB', maxFileSizeMB, Number.MIN_VALUE, 1024)
79
+ const defaultTolerance = config.defaultTolerance ?? 1e-9
80
+ assertFiniteRange('defaultTolerance', defaultTolerance, 0, 1)
81
+ const evidenceRowLimit = config.evidenceRowLimit ?? 20
82
+ assertPositiveInt('evidenceRowLimit', evidenceRowLimit)
83
+
84
+ const allowedExtensions = (config.allowedExtensions ?? ['.csv', '.tsv', '.json', '.jsonl']).map((ext) => {
85
+ if (!/^\.[a-z0-9]+$/u.test(ext)) {
86
+ throw new TypeError(`allowedExtensions entries must be dot-prefixed lowercase extensions, got ${JSON.stringify(ext)}`)
87
+ }
88
+ return ext
89
+ })
90
+ if (allowedExtensions.length === 0) {
91
+ throw new TypeError('allowedExtensions must not be empty')
92
+ }
93
+
94
+ return {
95
+ enabled: config.enabled ?? true,
96
+ maxRows,
97
+ maxFileSizeMB,
98
+ defaultTolerance,
99
+ evidenceRowLimit,
100
+ allowedExtensions,
101
+ workspaceRoot: config.workspaceRoot ?? '',
102
+ storeReports: config.storeReports ?? true,
103
+ }
104
+ }