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
package/src/dataset.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { open, stat } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import { createHash } from 'node:crypto'
12
+ import { TextDecoder } from 'node:util'
12
13
  import type { JsonValue } from '@deepseek-ai/dsh-session'
13
14
  import type { ResolvedConfig } from './config.ts'
14
15
 
@@ -18,12 +19,22 @@ export type Cell = JsonValue
18
19
  /** One dataset row keyed by column name. */
19
20
  export type Row = Record<string, Cell>
20
21
 
22
+ /** Detected file encoding metadata (BOM presence + UTF-8 validity). */
23
+ export interface EncodingInfo {
24
+ /** `'utf-8'` when a UTF-8 byte-order mark was present, else `null`. */
25
+ readonly bom: 'utf-8' | null
26
+ /** Whether the bytes decode as valid UTF-8 (no replacement characters forced). */
27
+ readonly validUtf8: boolean
28
+ }
29
+
21
30
  /** A tabular dataset: ordered columns plus rows. */
22
31
  export interface Table {
23
32
  /** Column names in file order. */
24
33
  readonly columns: string[]
25
34
  /** Rows, each carrying every declared column (missing cells are `null`). */
26
35
  readonly rows: Row[]
36
+ /** Detected file encoding; present only for tables loaded from a file. */
37
+ readonly encoding?: EncodingInfo
27
38
  }
28
39
 
29
40
  /** The root form a document load returns (citation checking walks this). */
@@ -101,13 +112,39 @@ export function throwIfAborted(signal: AbortSignal | undefined): void {
101
112
  }
102
113
 
103
114
  /**
104
- * Read a dataset file under the size cap.
115
+ * Detect a UTF-8 byte-order mark and validate the byte sequence. Invalid
116
+ * UTF-8 is a data-quality finding the profile reports (`validUtf8: false`)
117
+ * rather than a structural error that blocks the read — the decoded text
118
+ * keeps U+FFFD replacement characters so the profile can still run.
119
+ * @param buffer - raw file bytes.
120
+ * @returns the encoding metadata.
121
+ */
122
+ export function detectEncoding(buffer: Uint8Array): EncodingInfo {
123
+ const bom = buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf ? 'utf-8' : null
124
+ const body = bom === 'utf-8' ? buffer.subarray(3) : buffer
125
+ let validUtf8 = true
126
+ try {
127
+ new TextDecoder('utf-8', { fatal: true }).decode(body)
128
+ } catch {
129
+ validUtf8 = false
130
+ }
131
+ return { bom, validUtf8 }
132
+ }
133
+
134
+ /** Decode file bytes, stripping a UTF-8 BOM and preserving replacement characters for invalid bytes. */
135
+ function decodeUtf8Text(buffer: Uint8Array, encoding: EncodingInfo): string {
136
+ const body = encoding.bom === 'utf-8' ? buffer.subarray(3) : buffer
137
+ return new TextDecoder('utf-8').decode(body)
138
+ }
139
+
140
+ /**
141
+ * Read a dataset file under the size cap, detecting its encoding.
105
142
  * @param absolutePath - normalized absolute path (from {@link resolveWorkspacePath}).
106
143
  * @param config - resolved config (size cap).
107
144
  * @param signal - optional abort signal honored around the read.
108
- * @returns the UTF-8 text.
145
+ * @returns the decoded UTF-8 text plus its encoding metadata.
109
146
  */
110
- export async function readDatasetText(absolutePath: string, config: ResolvedConfig, signal?: AbortSignal): Promise<string> {
147
+ export async function readDatasetFile(absolutePath: string, config: ResolvedConfig, signal?: AbortSignal): Promise<{ text: string; encoding: EncodingInfo }> {
111
148
  throwIfAborted(signal)
112
149
  let info
113
150
  try {
@@ -128,7 +165,9 @@ export async function readDatasetText(absolutePath: string, config: ResolvedConf
128
165
  const handle = await open(absolutePath, 'r')
129
166
  try {
130
167
  throwIfAborted(signal)
131
- return await handle.readFile('utf8')
168
+ const buffer = await handle.readFile()
169
+ const encoding = detectEncoding(buffer)
170
+ return { text: decodeUtf8Text(buffer, encoding), encoding }
132
171
  } finally {
133
172
  await handle.close()
134
173
  }
@@ -325,12 +364,12 @@ function parseJsonLines(text: string): unknown[] {
325
364
  * @returns the parsed table.
326
365
  */
327
366
  export async function loadTable(absolutePath: string, config: ResolvedConfig, signal?: AbortSignal): Promise<Table> {
328
- const text = await readDatasetText(absolutePath, config, signal)
367
+ const { text, encoding } = await readDatasetFile(absolutePath, config, signal)
329
368
  const ext = path.extname(absolutePath).toLowerCase()
330
369
  throwIfAborted(signal)
331
- if (ext === '.csv') return parseDelimited(text, ',', config, signal)
332
- if (ext === '.tsv') return parseDelimited(text, '\t', config, signal)
333
- return parseJsonTable(text, ext, config, signal)
370
+ if (ext === '.csv') return { ...parseDelimited(text, ',', config, signal), encoding }
371
+ if (ext === '.tsv') return { ...parseDelimited(text, '\t', config, signal), encoding }
372
+ return { ...parseJsonTable(text, ext, config, signal), encoding }
334
373
  }
335
374
 
336
375
  /**
@@ -345,11 +384,11 @@ export async function loadTable(absolutePath: string, config: ResolvedConfig, si
345
384
  export async function loadDocument(absolutePath: string, config: ResolvedConfig, signal?: AbortSignal): Promise<DocumentRoot> {
346
385
  const ext = path.extname(absolutePath).toLowerCase()
347
386
  if (ext === '.json') {
348
- const text = await readDatasetText(absolutePath, config, signal)
387
+ const { text } = await readDatasetFile(absolutePath, config, signal)
349
388
  return { kind: 'json', value: parseJsonDocument(text) }
350
389
  }
351
390
  if (ext === '.jsonl') {
352
- const text = await readDatasetText(absolutePath, config, signal)
391
+ const { text } = await readDatasetFile(absolutePath, config, signal)
353
392
  return { kind: 'json', value: parseJsonLines(text) }
354
393
  }
355
394
  const table = await loadTable(absolutePath, config, signal)
@@ -405,18 +444,25 @@ const DATE_PATTERNS: readonly RegExp[] = [
405
444
  /^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/u,
406
445
  ]
407
446
 
408
- /**
409
- * Deterministic date parse to epoch milliseconds. Accepts `YYYY-MM-DD`,
410
- * `YYYY/MM/DD`, and ISO-like datetimes (date-only forms read as UTC midnight).
411
- * Calendar-invalid dates (e.g. 2025-13-40) reject. Returns `undefined` when
412
- * the cell is not a recognized date.
413
- * @param cell - the cell to parse (`undefined` when the column is absent).
414
- * @returns epoch milliseconds, or `undefined`.
415
- */
416
- export function parseDate(cell: Cell | undefined): number | undefined {
447
+ /** Deterministic date-format labels in {@link DATE_PATTERNS} order. */
448
+ const DATE_FORMATS = ['iso-date', 'slash-date', 'datetime'] as const
449
+
450
+ /** The format label of a recognized date cell (source pattern, not the epoch). */
451
+ export type DateFormat = (typeof DATE_FORMATS)[number]
452
+
453
+ /** A recognized date cell: its parsed epoch plus the source format label. */
454
+ export interface DateParse {
455
+ /** Epoch milliseconds (UTC). */
456
+ readonly epoch: number
457
+ /** Which {@link DATE_PATTERNS} entry matched. */
458
+ readonly format: DateFormat
459
+ }
460
+
461
+ /** Parse one date cell to its epoch plus format; `undefined` when unrecognized. */
462
+ function parseDateCellInternal(cell: Cell | undefined): DateParse | undefined {
417
463
  if (typeof cell !== 'string') return undefined
418
464
  const text = cell.trim()
419
- for (const pattern of DATE_PATTERNS) {
465
+ for (const [index, pattern] of DATE_PATTERNS.entries()) {
420
466
  const match = pattern.exec(text)
421
467
  if (match === null) continue
422
468
  const year = Number(match[1])
@@ -429,11 +475,45 @@ export function parseDate(cell: Cell | undefined): number | undefined {
429
475
  const epoch = Date.UTC(year, month - 1, day, hour, minute, second)
430
476
  const check = new Date(epoch)
431
477
  if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return undefined
432
- return epoch
478
+ const format = DATE_FORMATS[index]
479
+ if (format === undefined) return undefined
480
+ return { epoch, format }
433
481
  }
434
482
  return undefined
435
483
  }
436
484
 
485
+ /**
486
+ * Deterministic date parse to epoch milliseconds. Accepts `YYYY-MM-DD`,
487
+ * `YYYY/MM/DD`, and ISO-like datetimes (date-only forms read as UTC midnight).
488
+ * Calendar-invalid dates (e.g. 2025-13-40) reject. Returns `undefined` when
489
+ * the cell is not a recognized date.
490
+ * @param cell - the cell to parse (`undefined` when the column is absent).
491
+ * @returns epoch milliseconds, or `undefined`.
492
+ */
493
+ export function parseDate(cell: Cell | undefined): number | undefined {
494
+ return parseDateCellInternal(cell)?.epoch
495
+ }
496
+
497
+ /**
498
+ * Parse one date cell to its epoch plus source format label.
499
+ * @param cell - the cell to parse (`undefined` when the column is absent).
500
+ * @returns the parsed date, or `undefined` when the cell is not a recognized date.
501
+ */
502
+ export function parseDateCell(cell: Cell | undefined): DateParse | undefined {
503
+ return parseDateCellInternal(cell)
504
+ }
505
+
506
+ /**
507
+ * The format label of a recognized date cell (`iso-date` / `slash-date` /
508
+ * `datetime`); `undefined` when the cell is not a recognized date. Used to
509
+ * measure a date column's format consistency.
510
+ * @param cell - the cell to inspect (`undefined` when the column is absent).
511
+ * @returns the source format label, or `undefined`.
512
+ */
513
+ export function dateFormatOf(cell: Cell | undefined): DateFormat | undefined {
514
+ return parseDateCellInternal(cell)?.format
515
+ }
516
+
437
517
  /** Boolean parse: true/false/yes/no/1/0, case-insensitive. */
438
518
  export function parseBoolean(cell: Cell | undefined): boolean | undefined {
439
519
  if (typeof cell === 'boolean') return cell
package/src/events.ts CHANGED
@@ -33,6 +33,8 @@ export interface DataQualityEventData {
33
33
  readonly rules?: number
34
34
  readonly failedRules?: number
35
35
  readonly passed?: boolean
36
+ readonly expectations?: number
37
+ readonly failedExpectations?: number
36
38
  }
37
39
  }
38
40
 
package/src/index.ts CHANGED
@@ -23,14 +23,15 @@ import { LocalDataQualityService } from './provider-local.ts'
23
23
  import { defineProfileTool } from './tools/profile.ts'
24
24
  import { defineCleanTool } from './tools/clean.ts'
25
25
  import { defineVerifyTool } from './tools/verify.ts'
26
+ import { defineReportTool } from './tools/report.ts'
26
27
  import { VERSION } from './version.ts'
27
28
 
28
29
  export const name = 'data-quality'
29
- /** The three model tools and the durable report domain. */
30
+ /** The four model tools and the durable report domain. */
30
31
  export const inject = ['tools', 'storageDomain']
31
32
 
32
33
  export { Config, resolveConfig } from './config.ts'
33
- export type { Config as DataQualityConfig, ResolvedConfig } from './config.ts'
34
+ export type { Config as DataQualityConfig, ResolvedConfig, ScorecardWeights } from './config.ts'
34
35
  export { VERSION } from './version.ts'
35
36
  export {
36
37
  DataQualityService,
@@ -43,16 +44,21 @@ export {
43
44
  } from './service.ts'
44
45
  export { LocalDataQualityService, type ProviderDeps } from './provider-local.ts'
45
46
  export type { CleanRule, CleanRuleLog, CleanResult } from './clean.ts'
46
- export type { ProfileReport, ColumnProfile, NumericProfile } from './profile.ts'
47
- export type { VerifyRule, VerifyReport, VerifyRuleResult, VerifyEvidenceRow } from './verify.ts'
48
- export { parseLocator, checkCitations } from './verify.ts'
49
- export { profileTable, renderProfileText } from './profile.ts'
47
+ export { computeCleanContract, computeCleanProfileDiff, type CleanContractSummary, type CleanProfileDiff } from './contract.ts'
48
+ export type { ProfileReport, ColumnProfile, NumericProfile, DuplicateDetection, InferredType } from './profile.ts'
49
+ export type { VerifyRule, VerifyReport, VerifyRuleResult, VerifyEvidenceRow, VerifyExpectation, VerifyExpectationResult, VerifyMetric } from './verify.ts'
50
+ export { parseLocator, checkCitations, verifyExpectations, VerifyExpectationError } from './verify.ts'
51
+ export { profileTable, renderProfileText, detectDuplicateRows, countDuplicateRows, numericProfile } from './profile.ts'
52
+ export { computeScorecard, type DataQualityScorecard, type ScorecardDimension, type ScorecardDimensionName } from './scorecard.ts'
53
+ export { INDUSTRY_PRESETS, INDUSTRY_PRESET_IDS, resolveIndustryPreset, type IndustryPreset } from './presets.ts'
50
54
  export { applyCleanRules, serializeDelimited } from './clean.ts'
51
55
  export { verifyTable, renderVerifyText } from './verify.ts'
52
56
  export {
53
57
  DatasetError,
54
58
  loadTable,
55
59
  loadDocument,
60
+ readDatasetFile,
61
+ detectEncoding,
56
62
  parseDelimited,
57
63
  parseJsonTable,
58
64
  resolveWorkspacePath,
@@ -60,13 +66,18 @@ export {
60
66
  isMissing,
61
67
  parseNumeric,
62
68
  parseDate,
69
+ parseDateCell,
70
+ dateFormatOf,
63
71
  parseBoolean,
64
72
  type Table,
65
73
  type Row,
66
74
  type Cell,
67
75
  type DocumentRoot,
76
+ type DateParse,
77
+ type DateFormat,
78
+ type EncodingInfo,
68
79
  } from './dataset.ts'
69
- export { dataQualityDomainSpec, reportKeyOf, reportRecordSchema, type ReportRecord, type ReportStore } from './store.ts'
80
+ export { dataQualityDomainSpec, reportKeyOf, reportRecordSchema, isValidReportKey, type ReportRecord, type ReportStore, type StoredReport } from './store.ts'
70
81
  export { DATA_QUALITY_EVENT_TYPES, appendDataQualityEvent, type DataQualityEventData, type DataQualityEventType } from './events.ts'
71
82
  export { MAX_CELL_TEXT, truncateCell, truncateRow } from './present.ts'
72
83
 
@@ -97,6 +108,11 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
97
108
  return key
98
109
  },
99
110
  get: (key) => reports.get(key),
111
+ list: (kind) =>
112
+ [...reports.entries()]
113
+ .filter(([, record]) => record.kind === kind)
114
+ .sort(([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0))
115
+ .map(([key, record]) => ({ key, ...record })),
100
116
  }
101
117
  }
102
118
 
@@ -104,7 +120,8 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
104
120
  ctx.tools.register(defineProfileTool(service))
105
121
  ctx.tools.register(defineCleanTool(service))
106
122
  ctx.tools.register(defineVerifyTool(service))
107
- logger.info(`dsh-data-quality ${VERSION} mounted: ctx.dataQuality + data_profile/data_clean/data_verify`)
123
+ ctx.tools.register(defineReportTool(service))
124
+ logger.info(`dsh-data-quality ${VERSION} mounted: ctx.dataQuality + data_profile/data_clean/data_verify/data_report`)
108
125
 
109
126
  if (domain !== undefined) {
110
127
  const handle = domain
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,19 @@
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'
10
12
 
11
13
  /** Inferred column type from parsed cell classes. */
12
14
  export type InferredType = 'number' | 'date' | 'boolean' | 'string' | 'empty' | 'mixed'
13
15
 
14
16
  /** Numeric distribution of one numeric column. */
15
17
  export interface NumericProfile {
18
+ /** Number of numeric values the distribution covers. */
19
+ readonly count: number
20
+ /** Distinct numeric values among the profiled rows. */
21
+ readonly distinct: number
16
22
  readonly min: number
17
23
  readonly max: number
18
24
  readonly mean: number
@@ -41,6 +47,16 @@ export interface ColumnProfile {
41
47
  notes: string[]
42
48
  }
43
49
 
50
+ /** Full-content duplicate detection over ALL rows. */
51
+ export interface DuplicateDetection {
52
+ /** Rows whose full sha256 content duplicates an earlier row. */
53
+ readonly duplicateRows: number
54
+ /** `duplicateRows / rowCount` (0 when the table has no rows). */
55
+ readonly duplicateRate: number
56
+ /** 0-based indexes of duplicated rows, capped by the sample limit. */
57
+ readonly duplicateSampleRowIndexes: number[]
58
+ }
59
+
44
60
  /** The full profile report (also the persisted and tool-returned value). */
45
61
  export interface ProfileReport {
46
62
  readonly dataset: string
@@ -52,6 +68,14 @@ export interface ProfileReport {
52
68
  readonly columnCount: number
53
69
  /** Rows whose full content duplicates an earlier row (over ALL rows). */
54
70
  readonly duplicateRows: number
71
+ /** `duplicateRows / rowCount` (0 when the table has no rows). */
72
+ readonly duplicateRate: number
73
+ /** 0-based indexes of duplicated rows (capped by the configured sample limit). */
74
+ readonly duplicateSampleRowIndexes: number[]
75
+ /** DAMA six-dimension quality scorecard. */
76
+ readonly scorecard: DataQualityScorecard
77
+ /** Detected file encoding (BOM/UTF-8 validity); present only for file-loaded tables. */
78
+ readonly encoding?: EncodingInfo
55
79
  columns: ColumnProfile[]
56
80
  /** Storage-domain key of the persisted report, when persistence is on (set by the provider). */
57
81
  readonly reportKey?: string
@@ -59,6 +83,9 @@ export interface ProfileReport {
59
83
  readonly generatedAt: number
60
84
  }
61
85
 
86
+ /** Fallback duplicate-sample cap for direct engine use; the provider always passes the configured `evidenceRowLimit`. */
87
+ const DEFAULT_DUPLICATE_SAMPLE_LIMIT = 20
88
+
62
89
  /** Round to 6 significant digits for stable, readable report numbers. */
63
90
  function round6(value: number): number {
64
91
  return Number(value.toPrecision(6))
@@ -86,6 +113,8 @@ export function numericProfile(values: readonly number[]): NumericProfile | unde
86
113
  const highFence = p75 + 1.5 * iqr
87
114
  const outliers = iqr === 0 ? 0 : sorted.filter((value) => value < lowFence || value > highFence).length
88
115
  return {
116
+ count: sorted.length,
117
+ distinct: new Set(sorted).size,
89
118
  min: round6(sorted[0] as number),
90
119
  max: round6(sorted[sorted.length - 1] as number),
91
120
  mean: round6(sum / sorted.length),
@@ -96,20 +125,49 @@ export function numericProfile(values: readonly number[]): NumericProfile | unde
96
125
  }
97
126
  }
98
127
 
99
- /** Count rows whose full content duplicates an earlier row (first occurrence is not counted). */
100
- export function countDuplicateRows(table: Table, signal?: AbortSignal): number {
128
+ /** Deterministic sha256 key of one row's full content (columns in table order). */
129
+ function rowContentKey(table: Table, row: Row): string {
130
+ return createHash('sha256').update(JSON.stringify(table.columns.map((column) => row[column] ?? null))).digest('hex')
131
+ }
132
+
133
+ /**
134
+ * Detect full-content duplicate rows with a bounded sample of their 0-based
135
+ * indexes. The first occurrence of each content is never counted; later rows
136
+ * with identical full content are duplicates.
137
+ * @param table - the parsed dataset.
138
+ * @param options - sample cap and optional abort signal.
139
+ * @returns the duplicate count, rate, and capped sample indexes.
140
+ */
141
+ export function detectDuplicateRows(
142
+ table: Table,
143
+ options: { sampleLimit: number; signal?: AbortSignal | undefined },
144
+ ): DuplicateDetection {
145
+ if (!Number.isSafeInteger(options.sampleLimit) || options.sampleLimit <= 0) {
146
+ throw new TypeError(`sampleLimit must be a positive safe integer, got ${String(options.sampleLimit)}`)
147
+ }
101
148
  const seen = new Set<string>()
102
149
  let duplicates = 0
150
+ const duplicateSampleRowIndexes: number[] = []
103
151
  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))
152
+ if (index % 1024 === 0) throwIfAborted(options.signal)
153
+ const key = rowContentKey(table, row)
106
154
  if (seen.has(key)) {
107
155
  duplicates += 1
156
+ if (duplicateSampleRowIndexes.length < options.sampleLimit) duplicateSampleRowIndexes.push(index)
108
157
  } else {
109
158
  seen.add(key)
110
159
  }
111
160
  }
112
- return duplicates
161
+ return {
162
+ duplicateRows: duplicates,
163
+ duplicateRate: table.rows.length === 0 ? 0 : round6(duplicates / table.rows.length),
164
+ duplicateSampleRowIndexes,
165
+ }
166
+ }
167
+
168
+ /** Count rows whose full content duplicates an earlier row (first occurrence is not counted). */
169
+ export function countDuplicateRows(table: Table, signal?: AbortSignal): number {
170
+ return detectDuplicateRows(table, { sampleLimit: 1, signal }).duplicateRows
113
171
  }
114
172
 
115
173
  /** Profile one column over the given rows. */
@@ -206,19 +264,38 @@ function profileColumn(rows: readonly Row[], column: string, signal?: AbortSigna
206
264
  */
207
265
  export function profileTable(
208
266
  table: Table,
209
- options: { dataset: string; sample?: number | undefined; generatedAt: number; signal?: AbortSignal | undefined },
267
+ options: {
268
+ dataset: string
269
+ sample?: number | undefined
270
+ generatedAt: number
271
+ signal?: AbortSignal | undefined
272
+ duplicateSampleLimit?: number | undefined
273
+ declaredSchema?: Readonly<Record<string, InferredType>> | undefined
274
+ scorecardWeights?: Readonly<Record<ScorecardDimensionName, number>> | undefined
275
+ },
210
276
  ): ProfileReport {
211
277
  throwIfAborted(options.signal)
212
278
  const profiled = options.sample === undefined ? table.rows : sampleRows(table.rows, options.sample)
213
279
  const columns = table.columns.map((column) => profileColumn(profiled, column, options.signal))
214
- const duplicateRows = countDuplicateRows(table, options.signal)
280
+ const detection = detectDuplicateRows(table, { sampleLimit: options.duplicateSampleLimit ?? DEFAULT_DUPLICATE_SAMPLE_LIMIT, signal: options.signal })
281
+ const scorecard = computeScorecard(table, {
282
+ now: options.generatedAt,
283
+ duplicateRows: detection.duplicateRows,
284
+ declaredSchema: options.declaredSchema,
285
+ weights: options.scorecardWeights,
286
+ signal: options.signal,
287
+ })
215
288
  return {
216
289
  dataset: options.dataset,
217
290
  rowCount: table.rows.length,
218
291
  sampled: profiled.length !== table.rows.length,
219
292
  profiledRows: profiled.length,
220
293
  columnCount: table.columns.length,
221
- duplicateRows,
294
+ duplicateRows: detection.duplicateRows,
295
+ duplicateRate: detection.duplicateRate,
296
+ duplicateSampleRowIndexes: detection.duplicateSampleRowIndexes,
297
+ scorecard,
298
+ ...(table.encoding !== undefined ? { encoding: table.encoding } : {}),
222
299
  columns,
223
300
  generatedAt: options.generatedAt,
224
301
  }
@@ -229,14 +306,17 @@ export function renderProfileText(report: ProfileReport): string {
229
306
  const lines: string[] = []
230
307
  lines.push(`Profile of ${report.dataset}: ${report.rowCount} rows x ${report.columnCount} columns` +
231
308
  (report.sampled ? ` (column cards over a systematic sample of ${report.profiledRows} rows)` : ''))
232
- if (report.duplicateRows > 0) lines.push(`Duplicate rows: ${report.duplicateRows}`)
309
+ if (report.duplicateRows > 0) {
310
+ lines.push(`Duplicate rows: ${report.duplicateRows} (${(report.duplicateRate * 100).toFixed(1)}%)` +
311
+ (report.duplicateSampleRowIndexes.length > 0 ? `; sample row indexes: ${report.duplicateSampleRowIndexes.join(', ')}` : ''))
312
+ }
233
313
  for (const column of report.columns) {
234
314
  const parts = [`${column.name}: ${column.inferredType}`]
235
315
  if (column.missing > 0) parts.push(`missing ${column.missing} (${(column.missingRate * 100).toFixed(1)}%)`)
236
316
  parts.push(`unique ${column.unique}`)
237
317
  if (column.numeric !== undefined) {
238
318
  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}` +
319
+ `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
320
  (column.numeric.outliers > 0 ? `, ${column.numeric.outliers} IQR outliers` : ''),
241
321
  )
242
322
  }
@@ -246,5 +326,15 @@ export function renderProfileText(report: ProfileReport): string {
246
326
  for (const note of column.notes) parts.push(`note: ${note}`)
247
327
  lines.push(`- ${parts.join('; ')}`)
248
328
  }
329
+ if (report.encoding !== undefined) {
330
+ lines.push(`Encoding: UTF-8${report.encoding.bom === 'utf-8' ? ' (BOM)' : ''}${report.encoding.validUtf8 ? '' : ' (INVALID UTF-8)'}`)
331
+ }
332
+ const overall = report.scorecard.overall
333
+ const weighted = report.scorecard.weightedOverall
334
+ lines.push(`Scorecard (overall ${overall === null ? 'undetermined' : `${(overall * 100).toFixed(1)}%`}, weighted ${weighted === null ? 'undetermined' : `${(weighted * 100).toFixed(1)}%`}):`)
335
+ for (const dimension of report.scorecard.dimensions) {
336
+ const value = dimension.score === null ? 'undetermined' : `${(dimension.score * 100).toFixed(1)}%`
337
+ lines.push(` ${dimension.name}: ${value} (${dimension.note})`)
338
+ }
249
339
  return lines.join('\n')
250
340
  }