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
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shared JSON-Schema spec for the profile report's canonical value, reused by
3
+ * `data_profile` (its output) and `data_clean` (the `diffPreview` before/after
4
+ * snapshots). One source of truth so the two tools never drift.
5
+ * @module dsh-data-quality/tools-profile-report-schema
6
+ */
7
+
8
+ const SCORECARD_DIMENSION_NAMES = ['completeness', 'uniqueness', 'validity', 'consistency', 'timeliness', 'accuracy'] as const
9
+
10
+ /** One column card's schema. */
11
+ export const COLUMN_PROFILE_SCHEMA = {
12
+ type: 'object',
13
+ properties: {
14
+ name: { type: 'string', required: true },
15
+ inferredType: { type: 'string', enum: ['number', 'date', 'boolean', 'string', 'empty', 'mixed'], required: true },
16
+ missing: { type: 'number', required: true },
17
+ missingRate: { type: 'number', required: true },
18
+ unique: { type: 'number', required: true },
19
+ numeric: {
20
+ type: 'object',
21
+ properties: {
22
+ count: { type: 'number', required: true },
23
+ distinct: { type: 'number', required: true },
24
+ min: { type: 'number', required: true },
25
+ max: { type: 'number', required: true },
26
+ mean: { type: 'number', required: true },
27
+ median: { type: 'number', required: true },
28
+ p25: { type: 'number', required: true },
29
+ p75: { type: 'number', required: true },
30
+ outliers: { type: 'number', required: true },
31
+ },
32
+ additionalProperties: false,
33
+ },
34
+ topValues: {
35
+ type: 'array',
36
+ items: {
37
+ type: 'object',
38
+ properties: {
39
+ value: { type: 'string', required: true },
40
+ count: { type: 'number', required: true },
41
+ },
42
+ additionalProperties: false,
43
+ },
44
+ },
45
+ notes: { type: 'array', items: { type: 'string' }, required: true },
46
+ },
47
+ additionalProperties: false,
48
+ } as const
49
+
50
+ /** The full profile report's schema. */
51
+ export const PROFILE_REPORT_SCHEMA = {
52
+ type: 'object',
53
+ properties: {
54
+ dataset: { type: 'string', required: true },
55
+ rowCount: { type: 'number', required: true },
56
+ sampled: { type: 'boolean', required: true },
57
+ profiledRows: { type: 'number', required: true },
58
+ columnCount: { type: 'number', required: true },
59
+ duplicateRows: { type: 'number', required: true },
60
+ duplicateRate: { type: 'number', required: true },
61
+ duplicateSampleRowIndexes: { type: 'array', items: { type: 'number' }, required: true },
62
+ scorecard: {
63
+ type: 'object',
64
+ properties: {
65
+ overall: { oneOf: [{ type: 'number' }, { type: 'null' }], required: true },
66
+ weightedOverall: { oneOf: [{ type: 'number' }, { type: 'null' }], required: true },
67
+ dimensions: {
68
+ type: 'array',
69
+ items: {
70
+ type: 'object',
71
+ properties: {
72
+ name: { type: 'string', enum: [...SCORECARD_DIMENSION_NAMES], required: true },
73
+ score: { oneOf: [{ type: 'number' }, { type: 'null' }], required: true },
74
+ note: { type: 'string', required: true },
75
+ },
76
+ additionalProperties: false,
77
+ },
78
+ required: true,
79
+ },
80
+ },
81
+ additionalProperties: false,
82
+ required: true,
83
+ },
84
+ encoding: {
85
+ type: 'object',
86
+ properties: {
87
+ bom: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true },
88
+ validUtf8: { type: 'boolean', required: true },
89
+ },
90
+ additionalProperties: false,
91
+ },
92
+ generatedAt: { type: 'number', required: true },
93
+ reportKey: { type: 'string' },
94
+ columns: { type: 'array', items: COLUMN_PROFILE_SCHEMA, required: true },
95
+ },
96
+ additionalProperties: false,
97
+ } as const
@@ -6,46 +6,10 @@
6
6
 
7
7
  import { defineTool } from '@deepseek-ai/dsh-tools'
8
8
  import type { DataQualityService } from '../service.ts'
9
- import { renderProfileText } from '../profile.ts'
9
+ import { renderProfileText, type ProfileReport } from '../profile.ts'
10
+ import { PROFILE_REPORT_SCHEMA } from './profile-report-schema.ts'
10
11
  import { workspaceOf } from './shared.ts'
11
12
 
12
- const COLUMN_PROFILE_SCHEMA = {
13
- type: 'object',
14
- properties: {
15
- name: { type: 'string', required: true },
16
- inferredType: { type: 'string', enum: ['number', 'date', 'boolean', 'string', 'empty', 'mixed'], required: true },
17
- missing: { type: 'number', required: true },
18
- missingRate: { type: 'number', required: true },
19
- unique: { type: 'number', required: true },
20
- numeric: {
21
- type: 'object',
22
- properties: {
23
- min: { type: 'number', required: true },
24
- max: { type: 'number', required: true },
25
- mean: { type: 'number', required: true },
26
- median: { type: 'number', required: true },
27
- p25: { type: 'number', required: true },
28
- p75: { type: 'number', required: true },
29
- outliers: { type: 'number', required: true },
30
- },
31
- additionalProperties: false,
32
- },
33
- topValues: {
34
- type: 'array',
35
- items: {
36
- type: 'object',
37
- properties: {
38
- value: { type: 'string', required: true },
39
- count: { type: 'number', required: true },
40
- },
41
- additionalProperties: false,
42
- },
43
- },
44
- notes: { type: 'array', items: { type: 'string' }, required: true },
45
- },
46
- additionalProperties: false,
47
- } as const
48
-
49
13
  /**
50
14
  * Build the `data_profile` tool definition against a mounted service.
51
15
  * @param service - the mounted ctx.dataQuality implementation.
@@ -56,35 +20,23 @@ export function defineProfileTool(service: DataQualityService) {
56
20
  name: 'data_profile',
57
21
  description: [
58
22
  'Profile a workspace CSV/TSV/JSON/JSONL dataset with deterministic TypeScript computation (no mental math).',
59
- 'Returns row/column counts, inferred column types, missing rates, unique counts, numeric distributions (min/max/mean/median/p25/p75), IQR outlier counts, mixed-type suspicion notes, and duplicate-row counts.',
60
- 'Column cards cover every row by default; pass sample for a deterministic systematic sample on large files. Datasets above the configured row/size caps are rejected — use sample or raise the caps. The full report persists to the data_quality storage domain (reportKey in the result).',
23
+ 'Returns row/column counts, inferred column types, missing rates, unique counts, numeric distributions (count/distinct/min/max/mean/median/p25/p75), IQR outlier counts, mixed-type suspicion notes, sha256 duplicate-row detection (rate + sample indexes), file encoding (BOM/UTF-8 validity), and a weighted DAMA six-dimension scorecard.',
24
+ 'Pass industryPreset (retail/saas/fund/real-estate/e-commerce/healthcare/logistics/manufacturing/energy) to compare the dataset against that industry\'s expected columns, making the scorecard accuracy dimension determinable. Column cards cover every row by default; pass sample for a deterministic systematic sample on large files. Datasets above the configured row/size caps are rejected — use sample or raise the caps. The full report persists to the data_quality storage domain (reportKey in the result).',
61
25
  ].join('\n'),
62
26
  parameters: {
63
27
  path: { type: 'string', required: true, description: 'Workspace-relative dataset path (.csv/.tsv/.json/.jsonl). JSON datasets must be an array of flat objects.' },
64
28
  sample: { type: 'number', description: 'Optional systematic sample size (every ceil(N/sample)-th row) for the column cards; row counts stay exact.' },
29
+ industryPreset: { type: 'string', enum: ['retail', 'saas', 'fund', 'real-estate', 'e-commerce', 'healthcare', 'logistics', 'manufacturing', 'energy'], description: 'Optional industry preset id; its expected columns feed the scorecard accuracy dimension.' },
65
30
  },
66
31
  output: {
67
- schema: {
68
- type: 'object',
69
- properties: {
70
- dataset: { type: 'string', required: true },
71
- rowCount: { type: 'number', required: true },
72
- sampled: { type: 'boolean', required: true },
73
- profiledRows: { type: 'number', required: true },
74
- columnCount: { type: 'number', required: true },
75
- duplicateRows: { type: 'number', required: true },
76
- generatedAt: { type: 'number', required: true },
77
- reportKey: { type: 'string' },
78
- columns: { type: 'array', items: COLUMN_PROFILE_SCHEMA, required: true },
79
- },
80
- additionalProperties: false,
81
- },
82
- render: (_args, value) => [{ type: 'text', text: renderProfileText(value) }],
32
+ schema: PROFILE_REPORT_SCHEMA,
33
+ render: (_args, value) => [{ type: 'text', text: renderProfileText(value as unknown as ProfileReport) }],
83
34
  },
84
35
  async execute(args, exec) {
85
36
  return service.profileDataset({
86
37
  dataset: args.path,
87
38
  sample: args.sample,
39
+ ...(args.industryPreset !== undefined ? { industryPreset: args.industryPreset } : {}),
88
40
  workspace: workspaceOf(exec),
89
41
  session: exec.agent?.session,
90
42
  signal: exec.signal,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The `data_report` model tool: read persisted profile/clean-diff reports
3
+ * back from the `data_quality` storage domain, by exact `reportKey` or by
4
+ * `kind`. Deterministic read-only consumption — no model arithmetic.
5
+ * @module dsh-data-quality/tools/report
6
+ */
7
+
8
+ import { defineTool } from '@deepseek-ai/dsh-tools'
9
+ import type { JsonValue } from '@deepseek-ai/dsh-session'
10
+ import type { DataQualityService } from '../service.ts'
11
+ import type { ReportRecord, StoredReport } from '../store.ts'
12
+
13
+ /** The report kinds a caller may query. */
14
+ const REPORT_KINDS = ['profile', 'clean', 'clean-diff', 'verify', 'citations'] as const
15
+
16
+ /** One stored report projected into the model-facing canonical value (report as lossless JSON). */
17
+ interface ReportView {
18
+ readonly key: string
19
+ readonly kind: ReportRecord['kind']
20
+ readonly at: number
21
+ readonly dataset: string
22
+ readonly report: JsonValue
23
+ }
24
+
25
+ /** The canonical value the tool returns. */
26
+ interface DataReportValue {
27
+ readonly key?: string
28
+ readonly kind?: ReportRecord['kind']
29
+ readonly records: ReportView[]
30
+ }
31
+
32
+ /** Project a stored report into the canonical value (the stored report is already lossless JSON). */
33
+ function toView(record: StoredReport): ReportView {
34
+ return { key: record.key, kind: record.kind, at: record.at, dataset: record.dataset, report: record.report as unknown as JsonValue }
35
+ }
36
+
37
+ /** Human-readable report summary for the tool's Native render. */
38
+ function renderReportText(value: DataReportValue): string {
39
+ const lines: string[] = []
40
+ if (value.key !== undefined) {
41
+ lines.push(value.records.length === 0 ? `No report for ${value.key}` : `Report ${value.key}`)
42
+ } else {
43
+ lines.push(`Reports of kind ${value.kind ?? ''} (${value.records.length})`)
44
+ }
45
+ for (const record of value.records) {
46
+ lines.push(`- ${record.key} [${record.kind}] ${record.dataset} @ ${new Date(record.at).toISOString()}`)
47
+ }
48
+ return lines.join('\n')
49
+ }
50
+
51
+ /**
52
+ * Build the `data_report` tool definition against a mounted service.
53
+ * @param service - the mounted ctx.dataQuality implementation.
54
+ * @returns the tool definition to register.
55
+ */
56
+ export function defineReportTool(service: DataQualityService) {
57
+ return defineTool({
58
+ name: 'data_report',
59
+ description: [
60
+ 'Read persisted data-quality reports back from the data_quality storage domain (deterministic, read-only).',
61
+ 'Pass key (the exact reportKey a prior run returned) to fetch one report, or kind to list every persisted report of that kind, ordered chronologically. Exactly one of key/kind.',
62
+ 'Returns the report envelope(s): kind, dataset, timestamp, and the full stored report (profile/clean/clean-diff/verify/citations). Missing keys and unknown kinds fail loudly.',
63
+ ].join('\n'),
64
+ parameters: {
65
+ key: { type: 'string', description: 'Exact storage reportKey (e.g. 20260819000000000-profile-1a2b3c4d); fetches that one report.' },
66
+ kind: { type: 'string', enum: [...REPORT_KINDS], description: 'Report kind to list (profile/clean/clean-diff/verify/citations).' },
67
+ },
68
+ output: {
69
+ schema: {
70
+ type: 'object',
71
+ properties: {
72
+ key: { type: 'string' },
73
+ kind: { type: 'string', enum: [...REPORT_KINDS] },
74
+ records: {
75
+ type: 'array',
76
+ items: {
77
+ type: 'object',
78
+ properties: {
79
+ key: { type: 'string', required: true },
80
+ kind: { type: 'string', enum: [...REPORT_KINDS], required: true },
81
+ at: { type: 'number', required: true },
82
+ dataset: { type: 'string', required: true },
83
+ report: { type: 'json', required: true },
84
+ },
85
+ additionalProperties: false,
86
+ },
87
+ required: true,
88
+ },
89
+ },
90
+ additionalProperties: false,
91
+ },
92
+ render: (_args, value) => [{ type: 'text', text: renderReportText(value as unknown as DataReportValue) }],
93
+ },
94
+ async execute(args, _exec): Promise<DataReportValue> {
95
+ const hasKey = args.key !== undefined
96
+ const hasKind = args.kind !== undefined
97
+ if (hasKey === hasKind) {
98
+ throw new Error('data_report needs exactly one of key/kind')
99
+ }
100
+ if (hasKey) {
101
+ const record = await service.getReport(args.key as string)
102
+ return { key: args.key as string, records: [toView(record)] }
103
+ }
104
+ const records = await service.listReports(args.kind as ReportRecord['kind'])
105
+ return { kind: args.kind as ReportRecord['kind'], records: records.map(toView) }
106
+ },
107
+ })
108
+ }
@@ -27,11 +27,30 @@ export function workspaceOf(exec: ToolExecution): string {
27
27
  /** Human-readable cleaning summary for the tool's Native render. */
28
28
  export function renderCleanText(report: CleanRunReport): string {
29
29
  const lines: string[] = []
30
- lines.push(`Cleaned ${report.dataset}: ${report.inputRows} -> ${report.outputRows} rows over ${report.logs.length} rule(s)`)
30
+ const mode = report.dryRun ? 'Dry-run plan for' : 'Cleaned'
31
+ lines.push(`${mode} ${report.dataset}: ${report.inputRows} -> ${report.outputRows} rows over ${report.logs.length} rule(s)`)
31
32
  for (const log of report.logs) {
32
33
  lines.push(`- rule ${log.ruleIndex} (${log.rule}): ${log.affectedRows} row(s) affected; ${log.detail}`)
33
34
  }
34
- if (report.outputPath !== undefined) {
35
+ const contract = report.contract
36
+ const keyLabel = contract.dedupeColumns === null ? 'full rows' : `[${contract.dedupeColumns.join(', ')}]`
37
+ lines.push(`Contract: ${contract.inputRows} -> ${contract.outputRows} rows (${contract.removedRows} removed); uniqueness ${contract.uniqueKeys ? 'OK' : 'VIOLATED'} over ${keyLabel}${contract.remainingDuplicateRows > 0 ? ` (${contract.remainingDuplicateRows} duplicate row(s) remain)` : ''}`)
38
+ for (const entry of contract.remainingMissing) {
39
+ lines.push(`- non-null regression: ${entry.column} still has ${entry.count} missing cell(s)`)
40
+ }
41
+ for (const entry of contract.typeConformance) {
42
+ lines.push(`- type regression: ${entry.column} (${entry.to}) has ${entry.invalidCount} non-conforming cell(s)`)
43
+ }
44
+ for (const entry of contract.columnDecisions) {
45
+ const decisions = entry.decisions.map((decision) => `${decision.strategy} (${decision.affectedRows} row(s))`).join(', ')
46
+ lines.push(`- column ${entry.column}: ${decisions}`)
47
+ }
48
+ if (report.diffPreview !== undefined) {
49
+ lines.push(`Diff preview: ${report.diffPreview.before.rowCount} -> ${report.diffPreview.after.rowCount} rows; duplicates ${report.diffPreview.before.duplicateRows} -> ${report.diffPreview.after.duplicateRows}`)
50
+ }
51
+ if (report.dryRun) {
52
+ lines.push('Dry run: no output file written and no report persisted; plan/preview above.')
53
+ } else if (report.outputPath !== undefined) {
35
54
  lines.push(`Wrote cleaned dataset to ${report.outputPath}`)
36
55
  } else {
37
56
  lines.push('No outputPath given: the source file was left untouched; preview below.')
@@ -6,10 +6,44 @@
6
6
  */
7
7
 
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools'
9
- import type { DataQualityService, VerifyRule } from '../service.ts'
9
+ import type { DataQualityService, VerifyExpectation, VerifyRule } from '../service.ts'
10
10
  import { renderVerifyText, type VerifyReport } from '../verify.ts'
11
11
  import { workspaceOf } from './shared.ts'
12
12
 
13
+ const EXPECTATION_METRICS = ['rowCount', 'columnSum', 'columnMean', 'uniqueCount', 'nullCount'] as const
14
+
15
+ const EXPECTATION_SCHEMA = {
16
+ type: 'array',
17
+ items: {
18
+ type: 'object',
19
+ properties: {
20
+ metric: { type: 'string', enum: [...EXPECTATION_METRICS], required: true, description: 'Metric to reconcile: rowCount/columnSum/columnMean/uniqueCount/nullCount.' },
21
+ column: { type: 'string', description: 'Required for every metric except rowCount.' },
22
+ expected: { type: 'number', required: true, description: 'The expected value to reconcile against.' },
23
+ tolerance: { type: 'number', description: 'Optional relative tolerance in [0, 1]; defaults to defaultTolerance.' },
24
+ },
25
+ additionalProperties: false,
26
+ description: 'Reconcile a deterministic computed metric against an expected value with relative tolerance.',
27
+ },
28
+ description: 'Optional metric expectations; each yields passed true or passed false with actual/expected/tolerance detail.',
29
+ } as const
30
+
31
+ const EXPECTATION_RESULT_SCHEMA = {
32
+ type: 'array',
33
+ items: {
34
+ type: 'object',
35
+ properties: {
36
+ metric: { type: 'string', enum: [...EXPECTATION_METRICS], required: true },
37
+ column: { type: 'string' },
38
+ expected: { type: 'number', required: true },
39
+ actual: { type: 'number', required: true },
40
+ tolerance: { type: 'number', required: true },
41
+ passed: { type: 'boolean', required: true },
42
+ },
43
+ additionalProperties: false,
44
+ },
45
+ } as const
46
+
13
47
  const VERIFY_RULE_SCHEMA = {
14
48
  type: 'array',
15
49
  items: {
@@ -103,11 +137,13 @@ export function defineVerifyTool(service: DataQualityService) {
103
137
  description: [
104
138
  'Verify a workspace CSV/TSV/JSON/JSONL dataset against declarative quality rules with deterministic TypeScript computation (no mental math).',
105
139
  'Rules: not-null, unique (column group), range (numeric bounds), regex, enum, cross-column (e.g. startDate < endDate), freshness (date column within N days of asOf). A missing cell fails every rule that reads it.',
106
- 'Returns per-rule pass/fail with capped failing-row evidence. Overall failure is a NORMAL result with passed: false not a tool error. The full report persists to the data_quality storage domain (reportKey).',
140
+ 'Optional expectations reconcile deterministic metrics (rowCount/columnSum/columnMean/uniqueCount/nullCount) against expected values with relative tolerance; a mismatch is a normal passed: false with actual/expected/tolerance detail, never a tool error.',
141
+ 'Returns per-rule pass/fail with capped failing-row evidence plus the expectation outcomes. Overall failure is a NORMAL result with passed: false — not a tool error. The full report persists to the data_quality storage domain (reportKey).',
107
142
  ].join('\n'),
108
143
  parameters: {
109
144
  path: { type: 'string', required: true, description: 'Workspace-relative dataset path (.csv/.tsv/.json/.jsonl).' },
110
145
  rules: { ...VERIFY_RULE_SCHEMA, required: true },
146
+ expectations: { ...EXPECTATION_SCHEMA, description: 'Optional metric expectations to reconcile (rowCount/columnSum/columnMean/uniqueCount/nullCount).' },
111
147
  },
112
148
  output: {
113
149
  schema: {
@@ -145,6 +181,7 @@ export function defineVerifyTool(service: DataQualityService) {
145
181
  },
146
182
  required: true,
147
183
  },
184
+ expectations: { ...EXPECTATION_RESULT_SCHEMA, required: true },
148
185
  },
149
186
  additionalProperties: false,
150
187
  },
@@ -154,6 +191,7 @@ export function defineVerifyTool(service: DataQualityService) {
154
191
  return service.verifyDataset({
155
192
  dataset: args.path,
156
193
  rules: args.rules as unknown as readonly VerifyRule[],
194
+ ...(args.expectations !== undefined ? { expectations: args.expectations as unknown as readonly VerifyExpectation[] } : {}),
157
195
  workspace: workspaceOf(exec),
158
196
  session: exec.agent?.session,
159
197
  signal: exec.signal,
package/src/verify.ts CHANGED
@@ -41,6 +41,45 @@ export class VerifyRuleError extends Error {
41
41
  }
42
42
  }
43
43
 
44
+ /** The metrics a verification expectation can reconcile. */
45
+ export type VerifyMetric = 'rowCount' | 'columnSum' | 'columnMean' | 'uniqueCount' | 'nullCount'
46
+
47
+ /** One metric expectation: an expected value plus an optional relative tolerance. */
48
+ export interface VerifyExpectation {
49
+ readonly metric: VerifyMetric
50
+ /** Required for every metric except `rowCount`. */
51
+ readonly column?: string
52
+ readonly expected: number
53
+ /** Optional relative tolerance in [0, 1]; falls back to the configured `defaultTolerance`. */
54
+ readonly tolerance?: number
55
+ }
56
+
57
+ /** One expectation's reconciliation outcome. */
58
+ export interface VerifyExpectationResult {
59
+ readonly metric: VerifyMetric
60
+ /** Absent for `rowCount`. */
61
+ readonly column?: string
62
+ readonly expected: number
63
+ readonly actual: number
64
+ readonly tolerance: number
65
+ readonly passed: boolean
66
+ }
67
+
68
+ /** Raised for invalid expectations; `message` names the expectation index and reason. */
69
+ export class VerifyExpectationError extends Error {
70
+ /**
71
+ * @param expectationIndex - index of the offending expectation in the request array.
72
+ * @param message - actionable human-readable detail.
73
+ */
74
+ constructor(
75
+ readonly expectationIndex: number,
76
+ message: string,
77
+ ) {
78
+ super(message)
79
+ this.name = 'VerifyExpectationError'
80
+ }
81
+ }
82
+
44
83
  /** One failing row's evidence (capped per rule by `evidenceRowLimit`). */
45
84
  export interface VerifyEvidenceRow {
46
85
  /** 0-based data row index. */
@@ -68,6 +107,8 @@ export interface VerifyReport {
68
107
  readonly passed: boolean
69
108
  readonly rowCount: number
70
109
  readonly rules: VerifyRuleResult[]
110
+ /** Metric-reconciliation outcomes (empty when no expectations were given). */
111
+ readonly expectations: VerifyExpectationResult[]
71
112
  /** Storage-domain key of the persisted report, when persistence is on (set by the provider). */
72
113
  readonly reportKey?: string
73
114
  /** Injected generation timestamp (epoch ms). */
@@ -123,19 +164,118 @@ function evaluate(
123
164
 
124
165
  const CROSS_OPS = ['<', '<=', '==', '!=', '>=', '>'] as const
125
166
 
167
+ /** The metric ids, for validation and diagnostics. */
168
+ const VERIFY_METRICS = ['rowCount', 'columnSum', 'columnMean', 'uniqueCount', 'nullCount'] as const
169
+
170
+ /** Throw unless the expectation is well-formed (metric/column/tolerance). */
171
+ function validateExpectation(table: Table, expectation: VerifyExpectation, index: number): void {
172
+ if (!VERIFY_METRICS.includes(expectation.metric)) {
173
+ throw new VerifyExpectationError(index, `expectation ${index}: unknown metric ${JSON.stringify(expectation.metric)} (expected one of ${VERIFY_METRICS.join(', ')})`)
174
+ }
175
+ if (expectation.metric !== 'rowCount') {
176
+ if (expectation.column === undefined || expectation.column === '') {
177
+ throw new VerifyExpectationError(index, `expectation ${index}: metric ${expectation.metric} requires a column`)
178
+ }
179
+ if (!table.columns.includes(expectation.column)) {
180
+ throw new VerifyExpectationError(index, `expectation ${index}: unknown column ${JSON.stringify(expectation.column)} (columns: ${table.columns.join(', ')})`)
181
+ }
182
+ } else if (expectation.column !== undefined) {
183
+ throw new VerifyExpectationError(index, `expectation ${index}: metric rowCount takes no column, got ${JSON.stringify(expectation.column)}`)
184
+ }
185
+ if (expectation.tolerance !== undefined && (typeof expectation.tolerance !== 'number' || !Number.isFinite(expectation.tolerance) || expectation.tolerance < 0 || expectation.tolerance > 1)) {
186
+ throw new VerifyExpectationError(index, `expectation ${index}: tolerance must be a finite number in [0, 1], got ${String(expectation.tolerance)}`)
187
+ }
188
+ }
189
+
190
+ /** Compute the deterministic actual value of one expectation's metric. */
191
+ function metricValueOf(table: Table, expectation: VerifyExpectation): number {
192
+ switch (expectation.metric) {
193
+ case 'rowCount':
194
+ return table.rows.length
195
+ case 'nullCount': {
196
+ let count = 0
197
+ for (const row of table.rows) {
198
+ if (isMissing(row[expectation.column as string])) count += 1
199
+ }
200
+ return count
201
+ }
202
+ case 'uniqueCount': {
203
+ const distinct = new Set<string>()
204
+ for (const row of table.rows) {
205
+ const cell = row[expectation.column as string]
206
+ if (isMissing(cell)) continue
207
+ distinct.add(typeof cell === 'string' ? cell : JSON.stringify(cell))
208
+ }
209
+ return distinct.size
210
+ }
211
+ case 'columnSum':
212
+ case 'columnMean': {
213
+ let sum = 0
214
+ let count = 0
215
+ for (const row of table.rows) {
216
+ const value = parseNumeric(row[expectation.column as string])
217
+ if (value === undefined) continue
218
+ sum += value
219
+ count += 1
220
+ }
221
+ return expectation.metric === 'columnSum' ? sum : count === 0 ? 0 : sum / count
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Reconcile each expectation against its deterministic actual value. A
228
+ * mismatch is a normal `passed: false` result, never a thrown error; invalid
229
+ * metrics, columns, and tolerances fail loud.
230
+ * @param table - the parsed dataset.
231
+ * @param expectations - the expectations to reconcile.
232
+ * @param defaultTolerance - configured fallback relative tolerance.
233
+ * @param signal - optional abort signal.
234
+ * @returns one outcome per expectation.
235
+ */
236
+ export function verifyExpectations(
237
+ table: Table,
238
+ expectations: readonly VerifyExpectation[],
239
+ defaultTolerance: number,
240
+ signal?: AbortSignal,
241
+ ): VerifyExpectationResult[] {
242
+ return expectations.map((expectation, index) => {
243
+ throwIfAborted(signal)
244
+ validateExpectation(table, expectation, index)
245
+ const actual = metricValueOf(table, expectation)
246
+ const tolerance = expectation.tolerance ?? defaultTolerance
247
+ const passed = numericClose(actual, expectation.expected, tolerance)
248
+ return {
249
+ metric: expectation.metric,
250
+ ...(expectation.column !== undefined ? { column: expectation.column } : {}),
251
+ expected: expectation.expected,
252
+ actual,
253
+ tolerance,
254
+ passed,
255
+ }
256
+ })
257
+ }
258
+
126
259
  /**
127
260
  * Apply verification rules over a parsed table. A missing cell fails every
128
- * rule that reads it. The overall `passed` is the conjunction of rule passes;
129
- * a failing dataset is a normal result, never a thrown error.
261
+ * rule that reads it. The overall `passed` is the conjunction of rule passes
262
+ * and expectation passes; a failing dataset is a normal result, never a
263
+ * thrown error.
130
264
  * @param table - the parsed dataset.
131
265
  * @param rules - non-empty rule list.
132
- * @param options - evidence cap, injected clock for `freshness`, abort signal.
266
+ * @param options - evidence cap, injected clock for `freshness`, optional expectations, default tolerance, abort signal.
133
267
  * @returns the verify report (without the dataset label; the caller adds it).
134
268
  */
135
269
  export function verifyTable(
136
270
  table: Table,
137
271
  rules: readonly VerifyRule[],
138
- options: { evidenceRowLimit: number; now: () => number; signal?: AbortSignal | undefined },
272
+ options: {
273
+ evidenceRowLimit: number
274
+ now: () => number
275
+ signal?: AbortSignal | undefined
276
+ expectations?: readonly VerifyExpectation[] | undefined
277
+ defaultTolerance?: number | undefined
278
+ },
139
279
  ): Omit<VerifyReport, 'dataset'> {
140
280
  if (rules.length === 0) {
141
281
  throw new VerifyRuleError(0, 'rules must be a non-empty array')
@@ -332,10 +472,12 @@ export function verifyTable(
332
472
  }
333
473
  results.push(result)
334
474
  }
475
+ const expectations = verifyExpectations(table, options.expectations ?? [], options.defaultTolerance ?? 1e-9, options.signal)
335
476
  return {
336
- passed: results.every((result) => result.passed),
477
+ passed: results.every((result) => result.passed) && expectations.every((expectation) => expectation.passed),
337
478
  rowCount: table.rows.length,
338
479
  rules: results,
480
+ expectations,
339
481
  generatedAt: options.now(),
340
482
  }
341
483
  }
@@ -354,6 +496,10 @@ export function renderVerifyText(report: VerifyReport): string {
354
496
  lines.push(` … and ${rule.failedCount - rule.evidence.length} more failing row(s)`)
355
497
  }
356
498
  }
499
+ for (const expectation of report.expectations) {
500
+ const target = expectation.column !== undefined ? `${expectation.metric}(${expectation.column})` : expectation.metric
501
+ lines.push(`- [${expectation.passed ? 'pass' : 'FAIL'}] expectation ${target}: actual ${expectation.actual} vs expected ${expectation.expected} (tolerance ${expectation.tolerance})`)
502
+ }
357
503
  return lines.join('\n')
358
504
  }
359
505
 
package/src/version.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  */
6
6
 
7
7
  /** The package version reported in persisted reports. */
8
- export const VERSION = '0.1.2'
8
+ export const VERSION = '0.2.0'