tranfi 0.1.2 → 0.2.1

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 (132) hide show
  1. package/LICENSE +177 -0
  2. package/NOTICE +8 -0
  3. package/README.md +443 -51
  4. package/app/assets/{index-pDFMluyz.js → index-BIAIKnrp.js} +1 -1
  5. package/app/index.html +1 -1
  6. package/binding.gyp +55 -3
  7. package/csrc/arena.c +7 -5
  8. package/csrc/batch.c +818 -71
  9. package/csrc/buffer.c +84 -8
  10. package/csrc/cJSON.c +262 -19
  11. package/csrc/cJSON.h +17 -1
  12. package/csrc/codec_csv.c +1074 -181
  13. package/csrc/codec_jsonl.c +830 -118
  14. package/csrc/codec_table.c +108 -78
  15. package/csrc/codec_text.c +286 -68
  16. package/csrc/compiler.c +31 -3
  17. package/csrc/config.h +21 -0
  18. package/csrc/dsl.c +4722 -485
  19. package/csrc/expr.c +363 -55
  20. package/csrc/expr.h +2 -0
  21. package/csrc/internal.h +316 -27
  22. package/csrc/ir.c +65 -18
  23. package/csrc/ir.h +41 -0
  24. package/csrc/ir_schema.c +20 -5
  25. package/csrc/ir_serialize.c +68 -6
  26. package/csrc/ir_sql.c +796 -185
  27. package/csrc/ir_validate.c +462 -6
  28. package/csrc/json_path.c +210 -0
  29. package/csrc/main.c +879 -30
  30. package/csrc/memory_estimate.c +477 -0
  31. package/csrc/op_acf.c +171 -21
  32. package/csrc/op_across.c +477 -0
  33. package/csrc/op_anomaly.c +167 -32
  34. package/csrc/op_assert.c +761 -0
  35. package/csrc/op_bin.c +168 -29
  36. package/csrc/op_cast.c +383 -55
  37. package/csrc/op_clip.c +30 -19
  38. package/csrc/op_date_trunc.c +208 -34
  39. package/csrc/op_datetime.c +259 -77
  40. package/csrc/op_derive.c +65 -97
  41. package/csrc/op_diff.c +146 -30
  42. package/csrc/op_ewma.c +149 -30
  43. package/csrc/op_explode.c +124 -26
  44. package/csrc/op_fill_down.c +125 -53
  45. package/csrc/op_fill_null.c +176 -31
  46. package/csrc/op_filter.c +89 -40
  47. package/csrc/op_frequency.c +571 -43
  48. package/csrc/op_grep.c +36 -18
  49. package/csrc/op_group_agg.c +1790 -119
  50. package/csrc/op_hash.c +48 -15
  51. package/csrc/op_head.c +21 -86
  52. package/csrc/op_interpolate.c +268 -62
  53. package/csrc/op_join.c +2700 -182
  54. package/csrc/op_json_extract.c +227 -0
  55. package/csrc/op_json_filter.c +384 -0
  56. package/csrc/op_json_flatten.c +293 -0
  57. package/csrc/op_json_schema.c +503 -0
  58. package/csrc/op_label_encode.c +328 -53
  59. package/csrc/op_lag.c +181 -0
  60. package/csrc/op_lead.c +141 -89
  61. package/csrc/op_normalize.c +363 -79
  62. package/csrc/op_onehot.c +345 -73
  63. package/csrc/op_pivot.c +1546 -162
  64. package/csrc/op_quarantine.c +189 -0
  65. package/csrc/op_registry.c +2062 -166
  66. package/csrc/op_rename.c +41 -50
  67. package/csrc/op_replace.c +270 -118
  68. package/csrc/op_rleid.c +297 -0
  69. package/csrc/op_rowid.c +559 -0
  70. package/csrc/op_sample.c +80 -23
  71. package/csrc/op_schema.c +1341 -0
  72. package/csrc/op_schema_infer.c +252 -0
  73. package/csrc/op_select.c +265 -65
  74. package/csrc/op_set.c +3449 -0
  75. package/csrc/op_skip.c +30 -87
  76. package/csrc/op_sort.c +670 -124
  77. package/csrc/op_source_name.c +120 -0
  78. package/csrc/op_split.c +65 -28
  79. package/csrc/op_split_data.c +41 -9
  80. package/csrc/op_stack.c +178 -222
  81. package/csrc/op_stats.c +206 -110
  82. package/csrc/op_step.c +217 -55
  83. package/csrc/op_tail.c +21 -12
  84. package/csrc/op_tee.c +338 -0
  85. package/csrc/op_top.c +260 -53
  86. package/csrc/op_trim.c +48 -19
  87. package/csrc/op_unique.c +1193 -150
  88. package/csrc/op_unpivot.c +100 -66
  89. package/csrc/op_validate.c +601 -24
  90. package/csrc/op_window.c +492 -51
  91. package/csrc/path_policy.c +85 -0
  92. package/csrc/pipeline.c +872 -99
  93. package/csrc/recipes.c +3 -1
  94. package/csrc/report.c +73 -30
  95. package/csrc/selector.c +1097 -0
  96. package/csrc/size_utils.c +352 -0
  97. package/csrc/spill.c +317 -0
  98. package/csrc/spill.h +21 -0
  99. package/csrc/tranfi.h +169 -1
  100. package/csrc/transform.h +209 -0
  101. package/csrc/transform_api.c +2237 -0
  102. package/csrc/transform_categorical.c +923 -0
  103. package/csrc/transform_internal.h +472 -0
  104. package/csrc/transform_json.c +3812 -0
  105. package/csrc/transform_numeric.c +1966 -0
  106. package/csrc/transform_sha256.c +154 -0
  107. package/csrc/transform_wasm.h +162 -0
  108. package/csrc/transform_wasm_api.c +1373 -0
  109. package/csrc/wasm_api.c +70 -9
  110. package/napi_api.c +219 -11
  111. package/napi_transform.c +1648 -0
  112. package/napi_transform.h +8 -0
  113. package/package.json +27 -11
  114. package/scripts/install-native.js +76 -0
  115. package/scripts/prepack.js +64 -0
  116. package/scripts/sync-csrc.js +23 -0
  117. package/src/cli.js +81 -41
  118. package/src/engines/duckdb.js +45 -12
  119. package/src/index.js +661 -42
  120. package/src/memory_policy.js +411 -0
  121. package/src/native.js +1 -5
  122. package/src/pipeline.js +454 -31
  123. package/src/recipe_json.js +80 -0
  124. package/src/server.js +10 -8
  125. package/src/transform.js +403 -0
  126. package/src/transform_error.js +10 -0
  127. package/src/wasm.js +6 -4
  128. package/wasm/index.js +498 -10
  129. package/wasm/tranfi_core.js +0 -0
  130. package/wasm/transform.js +1156 -0
  131. package/wasm/worker.js +786 -0
  132. package/csrc/plan.c +0 -206
@@ -0,0 +1,761 @@
1
+ /*
2
+ * op_assert.c -- Data-quality assertions.
3
+ *
4
+ * Row mode:
5
+ * {"expr":"col('age') >= 0", "action":"fail|warn|filter|quarantine|annotate"}
6
+ * Aggregate mode:
7
+ * {"aggregate":"count|sum:col|avg:col|min:col|max:col|missing:col|non_null:col|missing_rate:col|complete_rate:col",
8
+ * "op":">=", "value":1, "action":"fail|warn"}
9
+ */
10
+
11
+ #include "internal.h"
12
+ #include "cJSON.h"
13
+ #include <ctype.h>
14
+ #include <math.h>
15
+ #include <stdio.h>
16
+ #include <stdlib.h>
17
+ #include <string.h>
18
+
19
+ typedef enum {
20
+ ASSERT_FAIL = 0,
21
+ ASSERT_WARN,
22
+ ASSERT_FILTER,
23
+ ASSERT_QUARANTINE,
24
+ ASSERT_ANNOTATE,
25
+ } assert_action;
26
+
27
+ typedef enum {
28
+ ASSERT_MODE_ROW = 0,
29
+ ASSERT_MODE_AGGREGATE,
30
+ } assert_mode;
31
+
32
+ typedef enum {
33
+ ASSERT_AGG_COUNT = 0,
34
+ ASSERT_AGG_NON_NULL,
35
+ ASSERT_AGG_MISSING,
36
+ ASSERT_AGG_SUM,
37
+ ASSERT_AGG_AVG,
38
+ ASSERT_AGG_MIN,
39
+ ASSERT_AGG_MAX,
40
+ ASSERT_AGG_MISSING_RATE,
41
+ ASSERT_AGG_COMPLETE_RATE,
42
+ } assert_agg_kind;
43
+
44
+ typedef enum {
45
+ ASSERT_CMP_EQ = 0,
46
+ ASSERT_CMP_NE,
47
+ ASSERT_CMP_LT,
48
+ ASSERT_CMP_LTE,
49
+ ASSERT_CMP_GT,
50
+ ASSERT_CMP_GTE,
51
+ } assert_cmp;
52
+
53
+ typedef struct {
54
+ tf_expr *expr;
55
+ char *expr_text;
56
+ char *name;
57
+ char *message;
58
+ char *result;
59
+ assert_action action;
60
+ assert_mode mode;
61
+ size_t row_index;
62
+ size_t failures;
63
+ size_t audit_limit;
64
+ size_t audit_emitted;
65
+ int audit;
66
+ tf_audit_options audit_opts;
67
+
68
+ assert_agg_kind agg_kind;
69
+ assert_cmp cmp;
70
+ char *agg_text;
71
+ char *agg_col;
72
+ int agg_col_index;
73
+ int agg_col_resolved;
74
+ double threshold;
75
+ double tolerance;
76
+ int relative_tolerance;
77
+ size_t agg_rows;
78
+ size_t agg_non_null;
79
+ size_t agg_missing;
80
+ double agg_sum;
81
+ double agg_min;
82
+ double agg_max;
83
+ int agg_has_numeric;
84
+ int agg_evaluated;
85
+ int agg_passed;
86
+ } assert_state;
87
+
88
+ static const char *assert_action_name(assert_action action) {
89
+ switch (action) {
90
+ case ASSERT_FAIL: return "fail";
91
+ case ASSERT_WARN: return "warn";
92
+ case ASSERT_FILTER: return "filter";
93
+ case ASSERT_QUARANTINE: return "quarantine";
94
+ case ASSERT_ANNOTATE: return "annotate";
95
+ default: return "fail";
96
+ }
97
+ }
98
+
99
+ static const char *assert_agg_kind_name(assert_agg_kind kind) {
100
+ switch (kind) {
101
+ case ASSERT_AGG_COUNT: return "count";
102
+ case ASSERT_AGG_NON_NULL: return "non_null";
103
+ case ASSERT_AGG_MISSING: return "missing";
104
+ case ASSERT_AGG_SUM: return "sum";
105
+ case ASSERT_AGG_AVG: return "avg";
106
+ case ASSERT_AGG_MIN: return "min";
107
+ case ASSERT_AGG_MAX: return "max";
108
+ case ASSERT_AGG_MISSING_RATE: return "missing_rate";
109
+ case ASSERT_AGG_COMPLETE_RATE: return "complete_rate";
110
+ default: return "count";
111
+ }
112
+ }
113
+
114
+ static const char *assert_cmp_name(assert_cmp cmp) {
115
+ switch (cmp) {
116
+ case ASSERT_CMP_EQ: return "==";
117
+ case ASSERT_CMP_NE: return "!=";
118
+ case ASSERT_CMP_LT: return "<";
119
+ case ASSERT_CMP_LTE: return "<=";
120
+ case ASSERT_CMP_GT: return ">";
121
+ case ASSERT_CMP_GTE: return ">=";
122
+ default: return "==";
123
+ }
124
+ }
125
+
126
+ static int parse_action(const char *s, assert_action *out) {
127
+ if (!s || strcmp(s, "fail") == 0 || strcmp(s, "error") == 0 || strcmp(s, "stop") == 0) {
128
+ *out = ASSERT_FAIL;
129
+ return 1;
130
+ }
131
+ if (strcmp(s, "warn") == 0 || strcmp(s, "warning") == 0) { *out = ASSERT_WARN; return 1; }
132
+ if (strcmp(s, "filter") == 0 || strcmp(s, "drop") == 0) { *out = ASSERT_FILTER; return 1; }
133
+ if (strcmp(s, "quarantine") == 0) { *out = ASSERT_QUARANTINE; return 1; }
134
+ if (strcmp(s, "annotate") == 0) { *out = ASSERT_ANNOTATE; return 1; }
135
+ return 0;
136
+ }
137
+
138
+ static int parse_cmp(const char *s, assert_cmp *out) {
139
+ if (!s || !s[0]) return 0;
140
+ if (strcmp(s, "==") == 0 || strcmp(s, "=") == 0 || strcmp(s, "eq") == 0) { *out = ASSERT_CMP_EQ; return 1; }
141
+ if (strcmp(s, "!=") == 0 || strcmp(s, "ne") == 0) { *out = ASSERT_CMP_NE; return 1; }
142
+ if (strcmp(s, "<") == 0 || strcmp(s, "lt") == 0) { *out = ASSERT_CMP_LT; return 1; }
143
+ if (strcmp(s, "<=") == 0 || strcmp(s, "lte") == 0 || strcmp(s, "le") == 0) { *out = ASSERT_CMP_LTE; return 1; }
144
+ if (strcmp(s, ">") == 0 || strcmp(s, "gt") == 0) { *out = ASSERT_CMP_GT; return 1; }
145
+ if (strcmp(s, ">=") == 0 || strcmp(s, "gte") == 0 || strcmp(s, "ge") == 0) { *out = ASSERT_CMP_GTE; return 1; }
146
+ return 0;
147
+ }
148
+
149
+ static int parse_agg_kind_name(const char *s, assert_agg_kind *out) {
150
+ if (!s || !s[0]) return 0;
151
+ if (strcmp(s, "count") == 0 || strcmp(s, "n") == 0 || strcmp(s, "rows") == 0) { *out = ASSERT_AGG_COUNT; return 1; }
152
+ if (strcmp(s, "non_null") == 0 || strcmp(s, "non-null") == 0 || strcmp(s, "complete") == 0 || strcmp(s, "present") == 0) { *out = ASSERT_AGG_NON_NULL; return 1; }
153
+ if (strcmp(s, "missing") == 0 || strcmp(s, "null") == 0 || strcmp(s, "nulls") == 0) { *out = ASSERT_AGG_MISSING; return 1; }
154
+ if (strcmp(s, "missing_rate") == 0 || strcmp(s, "missing-rate") == 0 ||
155
+ strcmp(s, "null_rate") == 0 || strcmp(s, "null-rate") == 0 ||
156
+ strcmp(s, "null_fraction") == 0 || strcmp(s, "null-fraction") == 0) { *out = ASSERT_AGG_MISSING_RATE; return 1; }
157
+ if (strcmp(s, "complete_rate") == 0 || strcmp(s, "complete-rate") == 0 ||
158
+ strcmp(s, "non_null_rate") == 0 || strcmp(s, "non-null-rate") == 0 ||
159
+ strcmp(s, "present_rate") == 0 || strcmp(s, "present-rate") == 0) { *out = ASSERT_AGG_COMPLETE_RATE; return 1; }
160
+ if (strcmp(s, "sum") == 0) { *out = ASSERT_AGG_SUM; return 1; }
161
+ if (strcmp(s, "avg") == 0 || strcmp(s, "mean") == 0) { *out = ASSERT_AGG_AVG; return 1; }
162
+ if (strcmp(s, "min") == 0) { *out = ASSERT_AGG_MIN; return 1; }
163
+ if (strcmp(s, "max") == 0) { *out = ASSERT_AGG_MAX; return 1; }
164
+ return 0;
165
+ }
166
+
167
+ static int agg_needs_column(assert_agg_kind kind) {
168
+ return kind != ASSERT_AGG_COUNT;
169
+ }
170
+
171
+ static int agg_needs_numeric(assert_agg_kind kind) {
172
+ return kind == ASSERT_AGG_SUM || kind == ASSERT_AGG_AVG ||
173
+ kind == ASSERT_AGG_MIN || kind == ASSERT_AGG_MAX;
174
+ }
175
+
176
+ static int parse_number_arg(const cJSON *item, double *out) {
177
+ if (cJSON_IsNumber(item)) { *out = item->valuedouble; return 1; }
178
+ if (cJSON_IsString(item) && item->valuestring) {
179
+ const char *s = item->valuestring;
180
+ while (*s && isspace((unsigned char)*s)) s++;
181
+ if (!*s) return 0;
182
+ char *end = NULL;
183
+ double v = strtod(s, &end);
184
+ if (!end || end == s) return 0;
185
+ while (*end && isspace((unsigned char)*end)) end++;
186
+ if (*end == '\0') { *out = v; return 1; }
187
+ }
188
+ return 0;
189
+ }
190
+
191
+ static int parse_aggregate_spec(const cJSON *args, assert_agg_kind *kind, char **agg_text, char **column) {
192
+ cJSON *agg_json = cJSON_GetObjectItemCaseSensitive(args, "aggregate");
193
+ if (!agg_json) agg_json = cJSON_GetObjectItemCaseSensitive(args, "agg");
194
+ if (!cJSON_IsString(agg_json) || !agg_json->valuestring || !agg_json->valuestring[0]) return 0;
195
+
196
+ char *spec = strdup(agg_json->valuestring);
197
+ if (!spec) return -1;
198
+ char *colon = strchr(spec, ':');
199
+ if (colon) *colon++ = '\0';
200
+ if (!parse_agg_kind_name(spec, kind)) {
201
+ free(spec);
202
+ return -2;
203
+ }
204
+
205
+ cJSON *col_json = cJSON_GetObjectItemCaseSensitive(args, "column");
206
+ if (!col_json) col_json = cJSON_GetObjectItemCaseSensitive(args, "col");
207
+ const char *col = cJSON_IsString(col_json) && col_json->valuestring && col_json->valuestring[0]
208
+ ? col_json->valuestring : colon;
209
+ int has_col = col && col[0];
210
+
211
+ *agg_text = strdup(agg_json->valuestring);
212
+ *column = has_col ? strdup(col) : NULL;
213
+ free(spec);
214
+ if (!*agg_text || (has_col && !*column)) {
215
+ free(*agg_text); *agg_text = NULL;
216
+ free(*column); *column = NULL;
217
+ return -1;
218
+ }
219
+ return 1;
220
+ }
221
+
222
+ static double aggregate_tolerance(double actual, double threshold, double tolerance,
223
+ int relative_tolerance) {
224
+ if (tolerance <= 0.0) return 0.0;
225
+ if (!relative_tolerance) return tolerance;
226
+ return tolerance * fmax(1.0, fmax(fabs(actual), fabs(threshold)));
227
+ }
228
+
229
+ static int compare_aggregate(double actual, double threshold, assert_cmp cmp,
230
+ double tolerance, int relative_tolerance) {
231
+ double eps = aggregate_tolerance(actual, threshold, tolerance, relative_tolerance);
232
+ double diff = fabs(actual - threshold);
233
+ switch (cmp) {
234
+ case ASSERT_CMP_EQ: return diff <= eps;
235
+ case ASSERT_CMP_NE: return diff > eps;
236
+ case ASSERT_CMP_LT: return actual < threshold;
237
+ case ASSERT_CMP_LTE: return actual <= threshold || diff <= eps;
238
+ case ASSERT_CMP_GT: return actual > threshold;
239
+ case ASSERT_CMP_GTE: return actual >= threshold || diff <= eps;
240
+ default: return 0;
241
+ }
242
+ }
243
+
244
+ static int aggregate_value(const assert_state *st, double *out) {
245
+ switch (st->agg_kind) {
246
+ case ASSERT_AGG_COUNT: *out = (double)st->agg_rows; return 1;
247
+ case ASSERT_AGG_NON_NULL: *out = (double)st->agg_non_null; return 1;
248
+ case ASSERT_AGG_MISSING: *out = (double)st->agg_missing; return 1;
249
+ case ASSERT_AGG_MISSING_RATE:
250
+ if (st->agg_rows == 0) return 0;
251
+ *out = (double)st->agg_missing / (double)st->agg_rows;
252
+ return 1;
253
+ case ASSERT_AGG_COMPLETE_RATE:
254
+ if (st->agg_rows == 0) return 0;
255
+ *out = (double)st->agg_non_null / (double)st->agg_rows;
256
+ return 1;
257
+ case ASSERT_AGG_SUM:
258
+ if (!st->agg_has_numeric) return 0;
259
+ *out = st->agg_sum;
260
+ return 1;
261
+ case ASSERT_AGG_AVG:
262
+ if (!st->agg_has_numeric || st->agg_non_null == 0) return 0;
263
+ *out = st->agg_sum / (double)st->agg_non_null;
264
+ return 1;
265
+ case ASSERT_AGG_MIN:
266
+ if (!st->agg_has_numeric) return 0;
267
+ *out = st->agg_min;
268
+ return 1;
269
+ case ASSERT_AGG_MAX:
270
+ if (!st->agg_has_numeric) return 0;
271
+ *out = st->agg_max;
272
+ return 1;
273
+ default:
274
+ return 0;
275
+ }
276
+ }
277
+
278
+ static int append_json_string_field(tf_buffer *out, const char *field, const char *value) {
279
+ cJSON *s = cJSON_CreateString(value ? value : "");
280
+ if (!s) return TF_ERROR;
281
+ char *printed = cJSON_PrintUnformatted(s);
282
+ cJSON_Delete(s);
283
+ if (!printed) return TF_ERROR;
284
+ int rc = tf_buffer_write_str(out, ",\"");
285
+ if (rc == TF_OK) rc = tf_buffer_write_str(out, field);
286
+ if (rc == TF_OK) rc = tf_buffer_write_str(out, "\":");
287
+ if (rc == TF_OK) rc = tf_buffer_write_str(out, printed);
288
+ free(printed);
289
+ return rc;
290
+ }
291
+
292
+ static int emit_assert_audit(assert_state *st, const tf_batch *b, size_t row,
293
+ tf_side_channels *side, const char *event) {
294
+ if (!st->audit || st->audit_emitted >= st->audit_limit || !side || !side->stats) return TF_OK;
295
+ cJSON *obj = cJSON_CreateObject();
296
+ if (!obj) return TF_ERROR;
297
+ int rc = TF_ERROR;
298
+ if (tf_json_add_string(obj, "type", "audit") != TF_OK ||
299
+ tf_json_add_string(obj, "op", "assert") != TF_OK ||
300
+ tf_json_add_string(obj, "event", event ? event : "row_dropped") != TF_OK ||
301
+ tf_json_add_string(obj, "reason", "assert_failed") != TF_OK ||
302
+ tf_json_add_string(obj, "channel", "audit") != TF_OK ||
303
+ tf_json_add_string(obj, "action", assert_action_name(st->action)) != TF_OK ||
304
+ tf_json_add_string(obj, "name", st->name ? st->name : "assert") != TF_OK ||
305
+ tf_json_add_string(obj, "expr", st->expr_text ? st->expr_text : "") != TF_OK ||
306
+ tf_json_add_number(obj, "row", (double)st->row_index) != TF_OK) {
307
+ goto done;
308
+ }
309
+ if (st->message && st->message[0] &&
310
+ tf_json_add_string(obj, "message", st->message) != TF_OK) {
311
+ goto done;
312
+ }
313
+ cJSON *row_obj = tf_audit_row_to_json(b, row, &st->audit_opts);
314
+ if (row_obj) {
315
+ if (tf_json_add_item(obj, "data", row_obj) != TF_OK) goto done;
316
+ } else if (st->audit_opts.include_row) {
317
+ goto done;
318
+ }
319
+ rc = tf_buffer_write_json_line(side->stats, obj);
320
+ done:
321
+ cJSON_Delete(obj);
322
+ if (rc == TF_OK) st->audit_emitted++;
323
+ return rc;
324
+ }
325
+
326
+ static int emit_failure(assert_state *st, const tf_batch *b, size_t row,
327
+ tf_side_channels *side, int include_row) {
328
+ if (!side || !side->errors) return TF_OK;
329
+ cJSON *obj = cJSON_CreateObject();
330
+ if (!obj) return TF_ERROR;
331
+ int rc = TF_ERROR;
332
+ if (tf_json_add_string(obj, "type", "assert_failure") != TF_OK ||
333
+ tf_json_add_string(obj, "op", "assert") != TF_OK ||
334
+ tf_json_add_string(obj, "action", assert_action_name(st->action)) != TF_OK ||
335
+ tf_json_add_string(obj, "severity", st->action == ASSERT_WARN ? "warning" : "error") != TF_OK ||
336
+ tf_json_add_string(obj, "name", st->name ? st->name : "assert") != TF_OK ||
337
+ tf_json_add_string(obj, "expr", st->expr_text ? st->expr_text : "") != TF_OK ||
338
+ tf_json_add_number(obj, "row", (double)st->row_index) != TF_OK) {
339
+ goto done;
340
+ }
341
+ if (st->message && st->message[0] &&
342
+ tf_json_add_string(obj, "message", st->message) != TF_OK) {
343
+ goto done;
344
+ }
345
+ if (include_row) {
346
+ cJSON *row_obj = tf_audit_row_to_json(b, row, &st->audit_opts);
347
+ if (row_obj) {
348
+ if (tf_json_add_item(obj, "data", row_obj) != TF_OK) goto done;
349
+ } else if (st->audit_opts.include_row) {
350
+ goto done;
351
+ }
352
+ }
353
+ rc = tf_buffer_write_json_line(side->errors, obj);
354
+ done:
355
+ cJSON_Delete(obj);
356
+ return rc;
357
+ }
358
+
359
+ static int emit_aggregate_failure(assert_state *st, tf_side_channels *side, double actual, int has_actual) {
360
+ if (!side || !side->errors) return TF_OK;
361
+ cJSON *obj = cJSON_CreateObject();
362
+ if (!obj) return TF_ERROR;
363
+ int rc = TF_ERROR;
364
+ if (tf_json_add_string(obj, "type", "assert_failure") != TF_OK ||
365
+ tf_json_add_string(obj, "op", "assert") != TF_OK ||
366
+ tf_json_add_string(obj, "reason", "aggregate_assert_failed") != TF_OK ||
367
+ tf_json_add_string(obj, "action", assert_action_name(st->action)) != TF_OK ||
368
+ tf_json_add_string(obj, "severity", st->action == ASSERT_WARN ? "warning" : "error") != TF_OK ||
369
+ tf_json_add_string(obj, "name", st->name ? st->name : "assert") != TF_OK ||
370
+ tf_json_add_string(obj, "aggregate", assert_agg_kind_name(st->agg_kind)) != TF_OK ||
371
+ tf_json_add_string(obj, "comparison", assert_cmp_name(st->cmp)) != TF_OK ||
372
+ tf_json_add_number(obj, "threshold", st->threshold) != TF_OK ||
373
+ tf_json_add_number(obj, "tolerance", st->tolerance) != TF_OK ||
374
+ tf_json_add_bool(obj, "relative_tolerance", st->relative_tolerance ? 1 : 0) != TF_OK) {
375
+ goto done;
376
+ }
377
+ if (st->agg_col && st->agg_col[0] &&
378
+ tf_json_add_string(obj, "column", st->agg_col) != TF_OK) {
379
+ goto done;
380
+ }
381
+ if (has_actual) {
382
+ if (tf_json_add_number(obj, "actual", actual) != TF_OK) goto done;
383
+ } else if (tf_json_add_null(obj, "actual") != TF_OK) {
384
+ goto done;
385
+ }
386
+ if (tf_json_add_number(obj, "rows", (double)st->agg_rows) != TF_OK ||
387
+ tf_json_add_number(obj, "non_null", (double)st->agg_non_null) != TF_OK ||
388
+ tf_json_add_number(obj, "missing", (double)st->agg_missing) != TF_OK) {
389
+ goto done;
390
+ }
391
+ if (st->message && st->message[0] &&
392
+ tf_json_add_string(obj, "message", st->message) != TF_OK) {
393
+ goto done;
394
+ }
395
+ rc = tf_buffer_write_json_line(side->errors, obj);
396
+ done:
397
+ cJSON_Delete(obj);
398
+ return rc;
399
+ }
400
+
401
+ static int resolve_aggregate_column(assert_state *st, const tf_batch *in) {
402
+ if (!agg_needs_column(st->agg_kind) || st->agg_col_resolved) return TF_OK;
403
+ int idx = tf_batch_col_index(in, st->agg_col);
404
+ if (idx < 0) {
405
+ char msg[256];
406
+ snprintf(msg, sizeof(msg), "assert: aggregate column not found: %s", st->agg_col ? st->agg_col : "");
407
+ tf_set_last_error(msg);
408
+ return TF_ERROR;
409
+ }
410
+ if (agg_needs_numeric(st->agg_kind) &&
411
+ in->col_types[idx] != TF_TYPE_INT64 && in->col_types[idx] != TF_TYPE_FLOAT64) {
412
+ char msg[256];
413
+ snprintf(msg, sizeof(msg), "assert: aggregate '%s' requires numeric column '%s'",
414
+ assert_agg_kind_name(st->agg_kind), st->agg_col ? st->agg_col : "");
415
+ tf_set_last_error(msg);
416
+ return TF_ERROR;
417
+ }
418
+ st->agg_col_index = idx;
419
+ st->agg_col_resolved = 1;
420
+ return TF_OK;
421
+ }
422
+
423
+ static int update_aggregate(assert_state *st, const tf_batch *in) {
424
+ if (resolve_aggregate_column(st, in) != TF_OK) return TF_ERROR;
425
+ for (size_t r = 0; r < in->n_rows; r++) {
426
+ st->agg_rows++;
427
+ if (st->agg_kind == ASSERT_AGG_COUNT) continue;
428
+ size_t c = (size_t)st->agg_col_index;
429
+ if (tf_batch_is_null(in, r, c)) {
430
+ st->agg_missing++;
431
+ continue;
432
+ }
433
+ st->agg_non_null++;
434
+ if (!agg_needs_numeric(st->agg_kind)) continue;
435
+ double v = in->col_types[c] == TF_TYPE_INT64
436
+ ? (double)tf_batch_get_int64(in, r, c)
437
+ : tf_batch_get_float64(in, r, c);
438
+ st->agg_sum += v;
439
+ if (!st->agg_has_numeric) {
440
+ st->agg_min = v;
441
+ st->agg_max = v;
442
+ st->agg_has_numeric = 1;
443
+ } else {
444
+ if (v < st->agg_min) st->agg_min = v;
445
+ if (v > st->agg_max) st->agg_max = v;
446
+ }
447
+ }
448
+ return TF_OK;
449
+ }
450
+
451
+ static int assert_process_aggregate(tf_step *self, tf_batch *in, tf_batch **out) {
452
+ assert_state *st = self->state;
453
+ *out = NULL;
454
+ if (update_aggregate(st, in) != TF_OK) return TF_ERROR;
455
+
456
+ tf_batch *ob = tf_batch_create(in->n_cols, in->n_rows > 0 ? in->n_rows : 1);
457
+ if (!ob) return TF_ERROR;
458
+ if (tf_batch_clone_schema(ob, in) != TF_OK) { tf_batch_free(ob); return TF_ERROR; }
459
+ for (size_t r = 0; r < in->n_rows; r++) {
460
+ if (tf_batch_copy_row(ob, r, in, r) != TF_OK) {
461
+ tf_batch_free(ob);
462
+ return TF_ERROR;
463
+ }
464
+ if (tf_batch_expose_row(ob, r) != TF_OK) {
465
+ tf_batch_free(ob);
466
+ return TF_ERROR;
467
+ }
468
+ }
469
+ *out = ob;
470
+ return TF_OK;
471
+ }
472
+
473
+ static int assert_process(tf_step *self, tf_batch *in, tf_batch **out,
474
+ tf_side_channels *side) {
475
+ assert_state *st = self->state;
476
+ *out = NULL;
477
+
478
+ if (st->mode == ASSERT_MODE_AGGREGATE) return assert_process_aggregate(self, in, out);
479
+
480
+ size_t extra = st->action == ASSERT_ANNOTATE ? 1u : 0u;
481
+ tf_batch *ob = tf_batch_create(in->n_cols + extra, in->n_rows > 0 ? in->n_rows : 1);
482
+ if (!ob) return TF_ERROR;
483
+ if (extra) {
484
+ const char *names[] = {st->result && st->result[0] ? st->result : "_assert"};
485
+ const tf_type types[] = {TF_TYPE_BOOL};
486
+ if (tf_batch_clone_with_extra_cols(ob, in, names, types, 1) != TF_OK) { tf_batch_free(ob); return TF_ERROR; }
487
+ } else if (tf_batch_clone_schema(ob, in) != TF_OK) {
488
+ tf_batch_free(ob);
489
+ return TF_ERROR;
490
+ }
491
+
492
+ size_t out_row = 0;
493
+ for (size_t r = 0; r < in->n_rows; r++) {
494
+ st->row_index++;
495
+ bool ok = false;
496
+ if (tf_expr_eval(st->expr, in, r, &ok) != TF_OK) ok = false;
497
+
498
+ if (!ok) {
499
+ st->failures++;
500
+ if (st->action == ASSERT_FAIL) {
501
+ char msg[512];
502
+ snprintf(msg, sizeof(msg), "assert failed at row %zu: %s", st->row_index,
503
+ st->message && st->message[0] ? st->message : (st->expr_text ? st->expr_text : "rule failed"));
504
+ tf_set_last_error(msg);
505
+ if (emit_failure(st, in, r, side, 1) != TF_OK) {
506
+ tf_batch_free(ob);
507
+ return TF_ERROR;
508
+ }
509
+ tf_batch_free(ob);
510
+ return TF_ERROR;
511
+ }
512
+ if (st->action == ASSERT_WARN) {
513
+ if (emit_failure(st, in, r, side, 1) != TF_OK) {
514
+ tf_batch_free(ob);
515
+ return TF_ERROR;
516
+ }
517
+ } else if (st->action == ASSERT_QUARANTINE) {
518
+ if (emit_failure(st, in, r, side, 1) != TF_OK) {
519
+ tf_batch_free(ob);
520
+ return TF_ERROR;
521
+ }
522
+ continue;
523
+ } else if (st->action == ASSERT_FILTER) {
524
+ if (emit_assert_audit(st, in, r, side, "row_dropped") != TF_OK) {
525
+ tf_batch_free(ob);
526
+ return TF_ERROR;
527
+ }
528
+ continue;
529
+ }
530
+ }
531
+
532
+ if (tf_batch_copy_row(ob, out_row, in, r) != TF_OK) {
533
+ tf_batch_free(ob);
534
+ return TF_ERROR;
535
+ }
536
+ if (st->action == ASSERT_ANNOTATE &&
537
+ tf_batch_set_bool(ob, out_row, in->n_cols, ok) != TF_OK) {
538
+ tf_batch_free(ob);
539
+ return TF_ERROR;
540
+ }
541
+ if (tf_batch_expose_row(ob, out_row) != TF_OK) {
542
+ tf_batch_free(ob);
543
+ return TF_ERROR;
544
+ }
545
+ out_row++;
546
+ }
547
+
548
+ if (ob->n_rows > 0 || st->action == ASSERT_ANNOTATE || in->n_rows == 0) *out = ob;
549
+ else tf_batch_free(ob);
550
+ return TF_OK;
551
+ }
552
+
553
+ static int assert_flush(tf_step *self, tf_batch **out, tf_side_channels *side) {
554
+ assert_state *st = self ? self->state : NULL;
555
+ *out = NULL;
556
+ if (!st || st->mode != ASSERT_MODE_AGGREGATE) return TF_OK;
557
+
558
+ double actual = 0.0;
559
+ int has_actual = aggregate_value(st, &actual);
560
+ st->agg_evaluated = 1;
561
+ st->agg_passed = has_actual && compare_aggregate(actual, st->threshold, st->cmp, st->tolerance, st->relative_tolerance);
562
+ if (st->agg_passed) return TF_OK;
563
+
564
+ st->failures = 1;
565
+ if (emit_aggregate_failure(st, side, actual, has_actual) != TF_OK) return TF_ERROR;
566
+ if (st->action == ASSERT_WARN) return TF_OK;
567
+
568
+ char msg[512];
569
+ if (has_actual) {
570
+ snprintf(msg, sizeof(msg), "assert aggregate failed: %s%s %s %.17g (actual %.17g)",
571
+ assert_agg_kind_name(st->agg_kind), st->agg_col ? ":" : "",
572
+ assert_cmp_name(st->cmp), st->threshold, actual);
573
+ } else {
574
+ snprintf(msg, sizeof(msg), "assert aggregate failed: %s%s has no numeric values",
575
+ assert_agg_kind_name(st->agg_kind), st->agg_col ? ":" : "");
576
+ }
577
+ tf_set_last_error(msg);
578
+ return TF_ERROR;
579
+ }
580
+
581
+ static int assert_append_stats(tf_step *self, tf_buffer *out) {
582
+ if (!self || !self->state || !out) return TF_ERROR;
583
+ assert_state *st = self->state;
584
+ if (st->mode != ASSERT_MODE_AGGREGATE) return TF_OK;
585
+ double actual = 0.0;
586
+ int has_actual = aggregate_value(st, &actual);
587
+ if (append_json_string_field(out, "assert_mode", "aggregate") != TF_OK) return TF_ERROR;
588
+ if (append_json_string_field(out, "aggregate", assert_agg_kind_name(st->agg_kind)) != TF_OK) return TF_ERROR;
589
+ if (st->agg_col && append_json_string_field(out, "aggregate_column", st->agg_col) != TF_OK) return TF_ERROR;
590
+ if (append_json_string_field(out, "comparison", assert_cmp_name(st->cmp)) != TF_OK) return TF_ERROR;
591
+ char buf[320];
592
+ snprintf(buf, sizeof(buf), ",\"threshold\":%.17g,\"tolerance\":%.17g,\"relative_tolerance\":%s,\"aggregate_rows\":%zu,\"aggregate_non_null\":%zu,\"aggregate_missing\":%zu,\"aggregate_passed\":%s",
593
+ st->threshold, st->tolerance, st->relative_tolerance ? "true" : "false",
594
+ st->agg_rows, st->agg_non_null, st->agg_missing,
595
+ st->agg_evaluated && st->agg_passed ? "true" : "false");
596
+ if (tf_buffer_write_str(out, buf) != TF_OK) return TF_ERROR;
597
+ if (has_actual) snprintf(buf, sizeof(buf), ",\"aggregate_value\":%.17g", actual);
598
+ else snprintf(buf, sizeof(buf), ",\"aggregate_value\":null");
599
+ return tf_buffer_write_str(out, buf);
600
+ }
601
+
602
+ static void assert_destroy(tf_step *self) {
603
+ if (!self) return;
604
+ assert_state *st = self->state;
605
+ if (st) {
606
+ tf_expr_free(st->expr);
607
+ free(st->expr_text);
608
+ free(st->name);
609
+ free(st->message);
610
+ free(st->result);
611
+ free(st->agg_text);
612
+ free(st->agg_col);
613
+ tf_audit_options_free(&st->audit_opts);
614
+ free(st);
615
+ }
616
+ free(self);
617
+ }
618
+
619
+ static void assert_state_free(assert_state *st) {
620
+ if (!st) return;
621
+ tf_expr_free(st->expr);
622
+ free(st->expr_text);
623
+ free(st->name);
624
+ free(st->message);
625
+ free(st->result);
626
+ free(st->agg_text);
627
+ free(st->agg_col);
628
+ free(st);
629
+ }
630
+
631
+ tf_step *tf_assert_create(const cJSON *args) {
632
+ if (!args) return NULL;
633
+
634
+ cJSON *expr_json = cJSON_GetObjectItemCaseSensitive(args, "expr");
635
+ cJSON *agg_json = cJSON_GetObjectItemCaseSensitive(args, "aggregate");
636
+ if (!agg_json) agg_json = cJSON_GetObjectItemCaseSensitive(args, "agg");
637
+ int has_expr = cJSON_IsString(expr_json) && expr_json->valuestring && expr_json->valuestring[0];
638
+ int has_aggregate = cJSON_IsString(agg_json) && agg_json->valuestring && agg_json->valuestring[0];
639
+ if (!has_expr && !has_aggregate) {
640
+ tf_set_last_error("assert: expr or aggregate is required");
641
+ return NULL;
642
+ }
643
+ if (has_expr && has_aggregate) {
644
+ tf_set_last_error("assert: use either expr or aggregate, not both");
645
+ return NULL;
646
+ }
647
+
648
+ cJSON *action_json = cJSON_GetObjectItemCaseSensitive(args, "action");
649
+ assert_action action = ASSERT_FAIL;
650
+ if (!parse_action(cJSON_IsString(action_json) ? action_json->valuestring : NULL, &action)) {
651
+ tf_set_last_error("assert: action must be fail, warn, filter, quarantine, or annotate");
652
+ return NULL;
653
+ }
654
+
655
+ assert_state *st = calloc(1, sizeof(assert_state));
656
+ if (!st) return NULL;
657
+ st->action = action;
658
+ st->mode = has_aggregate ? ASSERT_MODE_AGGREGATE : ASSERT_MODE_ROW;
659
+ st->audit_limit = 1000;
660
+ tf_audit_options_init(&st->audit_opts, 1);
661
+ st->agg_col_index = -1;
662
+ st->tolerance = 1e-12;
663
+ st->relative_tolerance = 1;
664
+
665
+ if (st->mode == ASSERT_MODE_ROW) {
666
+ st->expr = tf_expr_parse(expr_json->valuestring);
667
+ if (!st->expr) { assert_state_free(st); return NULL; }
668
+ st->expr_text = strdup(expr_json->valuestring);
669
+ } else {
670
+ if (action != ASSERT_FAIL && action != ASSERT_WARN) {
671
+ tf_set_last_error("assert: aggregate mode supports only fail or warn actions");
672
+ assert_state_free(st);
673
+ return NULL;
674
+ }
675
+ int prc = parse_aggregate_spec(args, &st->agg_kind, &st->agg_text, &st->agg_col);
676
+ if (prc == -2) tf_set_last_error("assert: unknown aggregate");
677
+ else if (prc <= 0) tf_set_last_error("assert: aggregate must be a non-empty string");
678
+ if (prc <= 0) { assert_state_free(st); return NULL; }
679
+ if (agg_needs_column(st->agg_kind) && (!st->agg_col || !st->agg_col[0])) {
680
+ tf_set_last_error("assert: aggregate requires a column");
681
+ assert_state_free(st);
682
+ return NULL;
683
+ }
684
+ cJSON *cmp_json = cJSON_GetObjectItemCaseSensitive(args, "op");
685
+ if (!cmp_json) cmp_json = cJSON_GetObjectItemCaseSensitive(args, "cmp");
686
+ if (!cmp_json) cmp_json = cJSON_GetObjectItemCaseSensitive(args, "comparison");
687
+ if (!parse_cmp(cJSON_IsString(cmp_json) ? cmp_json->valuestring : NULL, &st->cmp)) {
688
+ tf_set_last_error("assert: aggregate op must be one of ==, !=, <, <=, >, >=");
689
+ assert_state_free(st);
690
+ return NULL;
691
+ }
692
+ cJSON *value_json = cJSON_GetObjectItemCaseSensitive(args, "value");
693
+ if (!value_json) value_json = cJSON_GetObjectItemCaseSensitive(args, "threshold");
694
+ if (!parse_number_arg(value_json, &st->threshold)) {
695
+ tf_set_last_error("assert: aggregate value must be numeric");
696
+ assert_state_free(st);
697
+ return NULL;
698
+ }
699
+ cJSON *tol_json = cJSON_GetObjectItemCaseSensitive(args, "tolerance");
700
+ if (!tol_json) tol_json = cJSON_GetObjectItemCaseSensitive(args, "tol");
701
+ if (tol_json) {
702
+ if (!parse_number_arg(tol_json, &st->tolerance) || !isfinite(st->tolerance) || st->tolerance < 0.0) {
703
+ tf_set_last_error("assert: aggregate tolerance must be a non-negative finite number");
704
+ assert_state_free(st);
705
+ return NULL;
706
+ }
707
+ }
708
+ cJSON *rel_json = cJSON_GetObjectItemCaseSensitive(args, "rel");
709
+ if (!rel_json) rel_json = cJSON_GetObjectItemCaseSensitive(args, "relative");
710
+ if (rel_json) {
711
+ if (!cJSON_IsBool(rel_json)) {
712
+ tf_set_last_error("assert: aggregate rel must be true or false");
713
+ assert_state_free(st);
714
+ return NULL;
715
+ }
716
+ st->relative_tolerance = cJSON_IsTrue(rel_json) ? 1 : 0;
717
+ }
718
+ }
719
+
720
+ cJSON *audit_json = cJSON_GetObjectItemCaseSensitive(args, "audit");
721
+ st->audit = cJSON_IsTrue(audit_json) ? 1 : 0;
722
+ if (tf_audit_options_parse(&st->audit_opts, args, "assert") != TF_OK) {
723
+ assert_state_free(st);
724
+ return NULL;
725
+ }
726
+ cJSON *audit_limit_json = cJSON_GetObjectItemCaseSensitive(args, "audit_limit");
727
+ if (!audit_limit_json) audit_limit_json = cJSON_GetObjectItemCaseSensitive(args, "auditLimit");
728
+ if (audit_limit_json) {
729
+ size_t parsed_limit = 0;
730
+ if (tf_json_get_size_arg_any(args, "audit_limit", "auditLimit",
731
+ 1, TF_MAX_AUDIT_RECORDS,
732
+ &parsed_limit, "assert") < 0) {
733
+ assert_state_free(st);
734
+ return NULL;
735
+ }
736
+ st->audit_limit = parsed_limit;
737
+ }
738
+
739
+ cJSON *name_json = cJSON_GetObjectItemCaseSensitive(args, "name");
740
+ cJSON *message_json = cJSON_GetObjectItemCaseSensitive(args, "message");
741
+ cJSON *result_json = cJSON_GetObjectItemCaseSensitive(args, "result");
742
+ st->name = strdup(cJSON_IsString(name_json) && name_json->valuestring[0] ? name_json->valuestring : "assert");
743
+ st->message = strdup(cJSON_IsString(message_json) ? message_json->valuestring : "");
744
+ st->result = strdup(cJSON_IsString(result_json) && result_json->valuestring[0] ? result_json->valuestring : "_assert");
745
+ if (!st->name || !st->message || !st->result || (st->mode == ASSERT_MODE_ROW && !st->expr_text)) {
746
+ assert_state_free(st);
747
+ return NULL;
748
+ }
749
+
750
+ tf_step *step = calloc(1, sizeof(tf_step));
751
+ if (!step) {
752
+ assert_state_free(st);
753
+ return NULL;
754
+ }
755
+ step->process = assert_process;
756
+ step->flush = assert_flush;
757
+ step->append_stats = assert_append_stats;
758
+ step->destroy = assert_destroy;
759
+ step->state = st;
760
+ return step;
761
+ }