dsh-data-quality 0.1.3 → 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.
- package/CHANGELOG.md +26 -0
- package/README.es.md +7 -1
- package/README.hi.md +7 -1
- package/README.md +22 -9
- package/README.pt.md +7 -1
- package/README.zh.md +7 -1
- package/cordis.patch.yml +11 -0
- package/lib/index.js +1405 -140
- package/lib/types/config.d.ts +12 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +35 -0
- package/lib/types/config.js.map +1 -1
- package/lib/types/contract.d.ts +83 -0
- package/lib/types/contract.d.ts.map +1 -0
- package/lib/types/contract.js +144 -0
- package/lib/types/contract.js.map +1 -0
- package/lib/types/dataset.d.ts +50 -3
- package/lib/types/dataset.d.ts.map +1 -1
- package/lib/types/dataset.js +76 -21
- package/lib/types/dataset.js.map +1 -1
- package/lib/types/events.d.ts +2 -0
- package/lib/types/events.d.ts.map +1 -1
- package/lib/types/events.js.map +1 -1
- package/lib/types/index.d.ts +11 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +15 -6
- package/lib/types/index.js.map +1 -1
- package/lib/types/presets.d.ts +28 -0
- package/lib/types/presets.d.ts.map +1 -0
- package/lib/types/presets.js +134 -0
- package/lib/types/presets.js.map +1 -0
- package/lib/types/profile.d.ts +38 -1
- package/lib/types/profile.d.ts.map +1 -1
- package/lib/types/profile.js +64 -10
- package/lib/types/profile.js.map +1 -1
- package/lib/types/provider-local.d.ts +7 -1
- package/lib/types/provider-local.d.ts.map +1 -1
- package/lib/types/provider-local.js +72 -2
- package/lib/types/provider-local.js.map +1 -1
- package/lib/types/scorecard.d.ts +50 -0
- package/lib/types/scorecard.d.ts.map +1 -0
- package/lib/types/scorecard.js +186 -0
- package/lib/types/scorecard.js.map +1 -0
- package/lib/types/service.d.ts +32 -2
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js.map +1 -1
- package/lib/types/store.d.ts +21 -1
- package/lib/types/store.d.ts.map +1 -1
- package/lib/types/store.js +13 -1
- package/lib/types/store.js.map +1 -1
- package/lib/types/tools/clean.d.ts.map +1 -1
- package/lib/types/tools/clean.js +76 -1
- package/lib/types/tools/clean.js.map +1 -1
- package/lib/types/tools/profile-report-schema.d.ts +315 -0
- package/lib/types/tools/profile-report-schema.d.ts.map +1 -0
- package/lib/types/tools/profile-report-schema.js +95 -0
- package/lib/types/tools/profile-report-schema.js.map +1 -0
- package/lib/types/tools/profile.d.ts.map +1 -1
- package/lib/types/tools/profile.js +6 -53
- package/lib/types/tools/profile.js.map +1 -1
- package/lib/types/tools/report.d.ts +14 -0
- package/lib/types/tools/report.d.ts.map +1 -0
- package/lib/types/tools/report.js +86 -0
- package/lib/types/tools/report.js.map +1 -0
- package/lib/types/tools/shared.d.ts.map +1 -1
- package/lib/types/tools/shared.js +22 -2
- package/lib/types/tools/shared.js.map +1 -1
- package/lib/types/tools/verify.d.ts.map +1 -1
- package/lib/types/tools/verify.js +36 -1
- package/lib/types/tools/verify.js.map +1 -1
- package/lib/types/verify.d.ts +49 -3
- package/lib/types/verify.d.ts.map +1 -1
- package/lib/types/verify.js +111 -4
- package/lib/types/verify.js.map +1 -1
- package/lib/types/version.d.ts +1 -1
- package/lib/types/version.js +1 -1
- package/package.json +1 -1
- package/src/config.ts +52 -0
- package/src/contract.ts +190 -0
- package/src/dataset.ts +101 -21
- package/src/events.ts +2 -0
- package/src/index.ts +25 -8
- package/src/presets.ts +146 -0
- package/src/profile.ts +101 -11
- package/src/provider-local.ts +77 -3
- package/src/scorecard.ts +244 -0
- package/src/service.ts +34 -2
- package/src/store.ts +28 -2
- package/src/tools/clean.ts +76 -1
- package/src/tools/profile-report-schema.ts +97 -0
- package/src/tools/profile.ts +8 -56
- package/src/tools/report.ts +108 -0
- package/src/tools/shared.ts +21 -2
- package/src/tools/verify.ts +40 -2
- package/src/verify.ts +151 -5
- package/src/version.ts +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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
|
47
|
-
export type {
|
|
48
|
-
export {
|
|
49
|
-
export {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
100
|
-
|
|
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 =
|
|
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
|
|
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: {
|
|
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
|
|
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)
|
|
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
|
}
|