finelint 0.2.0__py3-none-any.whl

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.
finelint/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FineLint: deterministic facts for fine-tuning datasets."""
2
+
3
+ __version__ = "0.2.0"
finelint/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
finelint/analyzer.py ADDED
@@ -0,0 +1,461 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter, defaultdict, deque
5
+ from functools import lru_cache
6
+ from typing import Any
7
+
8
+ from .models import Finding, ReadResult, Record, ScanConfig
9
+ from .normalize import (
10
+ character_shingles,
11
+ jaccard,
12
+ minhash_bands,
13
+ minhash_signature,
14
+ normalize_text,
15
+ )
16
+
17
+ PREVIEW_LENGTH = 160
18
+ LSH_BUCKET_WINDOW = 64
19
+ MAX_NEAR_CANDIDATES = 32
20
+ MIN_SHARED_MINHASH_VALUES = 2
21
+
22
+
23
+ class ConfigurationError(Exception):
24
+ pass
25
+
26
+
27
+ def _type_name(value: Any) -> str:
28
+ if value is None:
29
+ return "null"
30
+ if isinstance(value, bool):
31
+ return "boolean"
32
+ if isinstance(value, str):
33
+ return "string"
34
+ if isinstance(value, (int, float)):
35
+ return "number"
36
+ if isinstance(value, list):
37
+ return "array"
38
+ if isinstance(value, dict):
39
+ return "object"
40
+ return type(value).__name__
41
+
42
+
43
+ def _is_identifier_field(name: str) -> bool:
44
+ lowered = name.casefold()
45
+ return lowered in {"id", "uuid", "guid"} or lowered.endswith("_id")
46
+
47
+
48
+ def choose_text_fields(result: ReadResult, requested: list[str] | None) -> list[str]:
49
+ if requested:
50
+ missing = [field for field in requested if field not in result.fields]
51
+ if missing:
52
+ raise ConfigurationError(f"Unknown field(s): {', '.join(missing)}")
53
+ return list(dict.fromkeys(requested))
54
+
55
+ counters: dict[str, Counter[str]] = {field: Counter() for field in result.fields}
56
+ for record in result.records:
57
+ for field in result.fields:
58
+ if field in record.data and record.data[field] is not None:
59
+ counters[field][_type_name(record.data[field])] += 1
60
+
61
+ selected = []
62
+ for field in result.fields:
63
+ counts = counters[field]
64
+ observed = sum(counts.values())
65
+ if (
66
+ observed
67
+ and counts["string"] / observed >= 0.8
68
+ and not _is_identifier_field(field)
69
+ ):
70
+ selected.append(field)
71
+ if not selected:
72
+ raise ConfigurationError(
73
+ "No text fields were detected. Select fields explicitly with --fields."
74
+ )
75
+ return selected
76
+
77
+
78
+ def _ref(record: Record, config: ScanConfig) -> dict[str, Any]:
79
+ value: dict[str, Any] = {"row": record.index, "line": record.line}
80
+ if config.id_field and config.id_field in record.data:
81
+ value["id"] = record.data[config.id_field]
82
+ return value
83
+
84
+
85
+ def _preview(value: Any) -> str:
86
+ if value is None:
87
+ return "null"
88
+ text = (
89
+ value
90
+ if isinstance(value, str)
91
+ else json.dumps(value, ensure_ascii=False, sort_keys=True)
92
+ )
93
+ return text if len(text) <= PREVIEW_LENGTH else text[: PREVIEW_LENGTH - 1] + "…"
94
+
95
+
96
+ def _raw_key(record: Record, fields: list[str]) -> str:
97
+ values = [[field, record.data.get(field)] for field in fields]
98
+ return json.dumps(
99
+ values, ensure_ascii=False, sort_keys=False, separators=(",", ":")
100
+ )
101
+
102
+
103
+ def _normalized_values(record: Record, fields: list[str]) -> tuple[str, ...]:
104
+ values: list[str] = []
105
+ for field in fields:
106
+ value = record.data.get(field)
107
+ if isinstance(value, str):
108
+ values.append(normalize_text(value))
109
+ elif value is None:
110
+ values.append("\0null")
111
+ else:
112
+ values.append(
113
+ "\0"
114
+ + json.dumps(
115
+ value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
116
+ )
117
+ )
118
+ return tuple(values)
119
+
120
+
121
+ def _combined_text(record: Record, fields: list[str]) -> str:
122
+ return "\n".join(
123
+ f"{field}: {value}"
124
+ for field, value in zip(fields, _normalized_values(record, fields))
125
+ )
126
+
127
+
128
+ def _validate_config(result: ReadResult, config: ScanConfig, fields: list[str]) -> None:
129
+ known = set(result.fields)
130
+ role_fields = config.input_fields + config.output_fields + config.required_fields
131
+ if config.id_field:
132
+ role_fields.append(config.id_field)
133
+ missing = sorted(set(role_fields) - known)
134
+ if missing:
135
+ raise ConfigurationError(f"Unknown field(s): {', '.join(missing)}")
136
+ if bool(config.input_fields) != bool(config.output_fields):
137
+ raise ConfigurationError(
138
+ "--input-fields and --output-fields must be used together."
139
+ )
140
+ if not 0.0 <= config.similarity_threshold <= 1.0:
141
+ raise ConfigurationError("--similarity-threshold must be between 0 and 1.")
142
+ if config.min_text_length < 1:
143
+ raise ConfigurationError("--min-text-length must be at least 1.")
144
+ if not fields:
145
+ raise ConfigurationError("At least one text field is required.")
146
+
147
+
148
+ def _structural_findings(
149
+ result: ReadResult, config: ScanConfig, fields: list[str]
150
+ ) -> list[Finding]:
151
+ findings: list[Finding] = []
152
+ required = set(config.required_fields)
153
+ presence = Counter(field for record in result.records for field in record.data)
154
+ common = {
155
+ field
156
+ for field, count in presence.items()
157
+ if result.records and count / len(result.records) >= 0.95
158
+ }
159
+
160
+ for record in result.records:
161
+ if not record.data or all(
162
+ value is None or (isinstance(value, str) and not value.strip())
163
+ for value in record.data.values()
164
+ ):
165
+ findings.append(
166
+ Finding(
167
+ "EMPTY_RECORD",
168
+ "structural",
169
+ "error",
170
+ "Record has no usable values.",
171
+ [_ref(record, config)],
172
+ )
173
+ )
174
+ for field in sorted(required):
175
+ value = record.data.get(field)
176
+ if value is None or (isinstance(value, str) and not value.strip()):
177
+ findings.append(
178
+ Finding(
179
+ "MISSING_REQUIRED_FIELD",
180
+ "structural",
181
+ "error",
182
+ f"Required field '{field}' is missing or empty.",
183
+ [_ref(record, config)],
184
+ [field],
185
+ )
186
+ )
187
+ for field in sorted(common - required):
188
+ if field not in record.data:
189
+ findings.append(
190
+ Finding(
191
+ "MISSING_COMMON_FIELD",
192
+ "structural",
193
+ "warning",
194
+ f"Field '{field}' exists in at least 95% of records but is missing here.",
195
+ [_ref(record, config)],
196
+ [field],
197
+ )
198
+ )
199
+ for field in fields:
200
+ value = record.data.get(field)
201
+ if value is None:
202
+ if field not in required and field in record.data:
203
+ findings.append(
204
+ Finding(
205
+ "NULL_TEXT_FIELD",
206
+ "structural",
207
+ "warning",
208
+ f"Text field '{field}' is null.",
209
+ [_ref(record, config)],
210
+ [field],
211
+ )
212
+ )
213
+ elif not isinstance(value, str):
214
+ findings.append(
215
+ Finding(
216
+ "NON_STRING_TEXT_FIELD",
217
+ "structural",
218
+ "error",
219
+ f"Text field '{field}' contains {_type_name(value)}.",
220
+ [_ref(record, config)],
221
+ [field],
222
+ {"preview": _preview(value)},
223
+ )
224
+ )
225
+ elif not value.strip() and field not in required:
226
+ findings.append(
227
+ Finding(
228
+ "BLANK_TEXT_FIELD",
229
+ "structural",
230
+ "warning",
231
+ f"Text field '{field}' is blank.",
232
+ [_ref(record, config)],
233
+ [field],
234
+ )
235
+ )
236
+
237
+ if result.format == "jsonl":
238
+ for field in result.fields:
239
+ typed = [
240
+ (record, _type_name(record.data[field]))
241
+ for record in result.records
242
+ if field in record.data and record.data[field] is not None
243
+ ]
244
+ counts = Counter(value_type for _, value_type in typed)
245
+ if len(counts) <= 1:
246
+ continue
247
+ dominant = min(counts.items(), key=lambda item: (-item[1], item[0]))[0]
248
+ for record, value_type in typed:
249
+ if value_type != dominant and not (
250
+ field in fields and value_type != "string"
251
+ ):
252
+ findings.append(
253
+ Finding(
254
+ "TYPE_MISMATCH",
255
+ "structural",
256
+ "warning",
257
+ f"Field '{field}' is usually {dominant} but this value is {value_type}.",
258
+ [_ref(record, config)],
259
+ [field],
260
+ {"preview": _preview(record.data[field])},
261
+ )
262
+ )
263
+ return findings
264
+
265
+
266
+ def _duplicate_findings(
267
+ result: ReadResult, config: ScanConfig, fields: list[str]
268
+ ) -> tuple[list[Finding], dict[tuple[str, ...], list[Record]]]:
269
+ findings: list[Finding] = []
270
+ raw_groups: dict[str, list[Record]] = defaultdict(list)
271
+ normalized_groups: dict[tuple[str, ...], list[Record]] = defaultdict(list)
272
+ for record in result.records:
273
+ raw_groups[_raw_key(record, fields)].append(record)
274
+ normalized_groups[_normalized_values(record, fields)].append(record)
275
+
276
+ for group in raw_groups.values():
277
+ if len(group) > 1:
278
+ findings.append(
279
+ Finding(
280
+ "EXACT_DUPLICATE",
281
+ "exact_duplicate",
282
+ "warning",
283
+ f"{len(group)} records contain exactly the same selected field values.",
284
+ [_ref(record, config) for record in group],
285
+ fields,
286
+ {"preview": _preview(_combined_text(group[0], fields))},
287
+ )
288
+ )
289
+
290
+ for normalized_key, group in normalized_groups.items():
291
+ if len(group) <= 1:
292
+ continue
293
+ raw_variants = {_raw_key(record, fields) for record in group}
294
+ if len(raw_variants) > 1:
295
+ findings.append(
296
+ Finding(
297
+ "NORMALIZED_DUPLICATE",
298
+ "normalized_duplicate",
299
+ "warning",
300
+ f"{len(group)} records match after Unicode, casing, and whitespace normalization.",
301
+ [_ref(record, config) for record in group],
302
+ fields,
303
+ {"normalized_preview": _preview("\n".join(normalized_key))},
304
+ )
305
+ )
306
+ return findings, normalized_groups
307
+
308
+
309
+ class _UnionFind:
310
+ def __init__(self, size: int) -> None:
311
+ self.parent = list(range(size))
312
+
313
+ def find(self, item: int) -> int:
314
+ while self.parent[item] != item:
315
+ self.parent[item] = self.parent[self.parent[item]]
316
+ item = self.parent[item]
317
+ return item
318
+
319
+ def union(self, left: int, right: int) -> None:
320
+ left_root, right_root = self.find(left), self.find(right)
321
+ if left_root != right_root:
322
+ self.parent[right_root] = left_root
323
+
324
+
325
+ def _near_duplicate_findings(
326
+ result: ReadResult,
327
+ config: ScanConfig,
328
+ fields: list[str],
329
+ normalized_groups: dict[tuple[str, ...], list[Record]],
330
+ ) -> list[Finding]:
331
+ representatives = [
332
+ group[0]
333
+ for key, group in normalized_groups.items()
334
+ if any(key)
335
+ and all(isinstance(group[0].data.get(field), str) for field in fields)
336
+ ]
337
+ texts = [_combined_text(record, fields) for record in representatives]
338
+ eligible = [
339
+ index for index, text in enumerate(texts) if len(text) >= config.min_text_length
340
+ ]
341
+ buckets: dict[tuple[int, int], deque[int]] = defaultdict(
342
+ lambda: deque(maxlen=LSH_BUCKET_WINDOW)
343
+ )
344
+ union = _UnionFind(len(representatives))
345
+ matches: list[tuple[int, int, float]] = []
346
+
347
+ @lru_cache(maxsize=1024)
348
+ def shingles(index: int) -> set[str]:
349
+ return character_shingles(texts[index])
350
+
351
+ for index in eligible:
352
+ signature = minhash_signature(shingles(index))
353
+ keys = minhash_bands(signature)
354
+ candidate_counts: Counter[int] = Counter()
355
+ for key in keys:
356
+ candidate_counts.update(buckets[key])
357
+ candidates = [
358
+ candidate
359
+ for candidate, shared_values in candidate_counts.most_common(
360
+ MAX_NEAR_CANDIDATES
361
+ )
362
+ if shared_values >= MIN_SHARED_MINHASH_VALUES
363
+ ]
364
+ current_shingles = shingles(index)
365
+ for other in sorted(candidates):
366
+ score = jaccard(current_shingles, shingles(other))
367
+ if score >= config.similarity_threshold:
368
+ union.union(index, other)
369
+ matches.append((other, index, score))
370
+ for key in keys:
371
+ buckets[key].append(index)
372
+
373
+ components: dict[int, set[int]] = defaultdict(set)
374
+ component_matches: dict[int, list[tuple[int, int, float]]] = defaultdict(list)
375
+ for left, right, score in matches:
376
+ root = union.find(left)
377
+ components[root].update((left, right))
378
+ component_matches[root].append((left, right, score))
379
+
380
+ findings: list[Finding] = []
381
+ for root in sorted(components, key=lambda item: min(components[item])):
382
+ member_indexes = sorted(components[root])
383
+ records: list[Record] = []
384
+ for index in member_indexes:
385
+ key = _normalized_values(representatives[index], fields)
386
+ records.extend(normalized_groups[key])
387
+ edge_values = [
388
+ {
389
+ "left_row": representatives[left].index,
390
+ "right_row": representatives[right].index,
391
+ "similarity": round(score, 6),
392
+ }
393
+ for left, right, score in sorted(component_matches[root])
394
+ ]
395
+ findings.append(
396
+ Finding(
397
+ "NEAR_DUPLICATE",
398
+ "near_duplicate",
399
+ "warning",
400
+ f"{len(records)} records form a highly similar group.",
401
+ [
402
+ _ref(record, config)
403
+ for record in sorted(records, key=lambda record: record.index)
404
+ ],
405
+ fields,
406
+ {"matches": edge_values, "preview": _preview(texts[member_indexes[0]])},
407
+ )
408
+ )
409
+ return findings
410
+
411
+
412
+ def _conflict_findings(result: ReadResult, config: ScanConfig) -> list[Finding]:
413
+ if not config.input_fields:
414
+ return []
415
+ grouped: dict[tuple[str, ...], list[Record]] = defaultdict(list)
416
+ for record in result.records:
417
+ role_fields = config.input_fields + config.output_fields
418
+ if not all(isinstance(record.data.get(field), str) for field in role_fields):
419
+ continue
420
+ input_key = _normalized_values(record, config.input_fields)
421
+ if not any(input_key):
422
+ continue
423
+ grouped[input_key].append(record)
424
+
425
+ findings: list[Finding] = []
426
+ for input_key, records in grouped.items():
427
+ if len(records) <= 1:
428
+ continue
429
+ outputs: dict[tuple[str, ...], list[Record]] = defaultdict(list)
430
+ for record in records:
431
+ outputs[_normalized_values(record, config.output_fields)].append(record)
432
+ if len(outputs) > 1:
433
+ findings.append(
434
+ Finding(
435
+ "CONFLICTING_OUTPUT",
436
+ "conflicting_output",
437
+ "error",
438
+ "The same normalized input has different normalized outputs.",
439
+ [_ref(record, config) for record in records],
440
+ config.input_fields + config.output_fields,
441
+ {
442
+ "input_preview": _preview("\n".join(input_key)),
443
+ "distinct_outputs": [
444
+ _preview("\n".join(output)) for output in outputs
445
+ ],
446
+ },
447
+ )
448
+ )
449
+ return findings
450
+
451
+
452
+ def analyze(result: ReadResult, config: ScanConfig) -> tuple[list[str], list[Finding]]:
453
+ fields = choose_text_fields(result, config.fields)
454
+ _validate_config(result, config, fields)
455
+ findings = list(result.findings)
456
+ findings.extend(_structural_findings(result, config, fields))
457
+ duplicates, normalized_groups = _duplicate_findings(result, config, fields)
458
+ findings.extend(duplicates)
459
+ findings.extend(_near_duplicate_findings(result, config, fields, normalized_groups))
460
+ findings.extend(_conflict_findings(result, config))
461
+ return fields, findings