tabalyst 0.4.0__tar.gz → 0.4.1__tar.gz

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 (97) hide show
  1. {tabalyst-0.4.0/src/tabalyst.egg-info → tabalyst-0.4.1}/PKG-INFO +1 -1
  2. {tabalyst-0.4.0 → tabalyst-0.4.1}/pyproject.toml +1 -1
  3. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/models.py +1 -1
  4. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/config.py +6 -0
  5. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/base.py +7 -0
  6. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/boolean.py +1 -0
  7. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/currency.py +1 -0
  8. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/email.py +1 -0
  9. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/ip.py +1 -0
  10. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/phone.py +1 -0
  11. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/postal.py +1 -0
  12. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/quantity.py +1 -0
  13. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/registry.py +164 -15
  14. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/temporal.py +1 -0
  15. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/url.py +1 -0
  16. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/uuid.py +1 -0
  17. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/measures.py +11 -6
  18. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/models.py +12 -1
  19. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/records.py +106 -5
  20. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/structure.py +35 -11
  21. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/values.py +79 -10
  22. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/service.py +6 -2
  23. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/templates/report.html +1 -1
  24. {tabalyst-0.4.0 → tabalyst-0.4.1/src/tabalyst.egg-info}/PKG-INFO +1 -1
  25. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_cli.py +2 -2
  26. {tabalyst-0.4.0 → tabalyst-0.4.1}/LICENSE +0 -0
  27. {tabalyst-0.4.0 → tabalyst-0.4.1}/MANIFEST.in +0 -0
  28. {tabalyst-0.4.0 → tabalyst-0.4.1}/README.md +0 -0
  29. {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/README.md +0 -0
  30. {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/config.json +0 -0
  31. {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/input/basic.csv +0 -0
  32. {tabalyst-0.4.0 → tabalyst-0.4.1}/examples/input/insurance-customers.csv +0 -0
  33. {tabalyst-0.4.0 → tabalyst-0.4.1}/setup.cfg +0 -0
  34. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/__init__.py +0 -0
  35. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/__main__.py +0 -0
  36. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/_version.py +0 -0
  37. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/batch.py +0 -0
  38. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/__init__.py +0 -0
  39. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/app.py +0 -0
  40. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/report.py +0 -0
  41. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/sample.py +0 -0
  42. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/scan.py +0 -0
  43. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/cli/terminal.py +0 -0
  44. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/config.py +0 -0
  45. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/errors.py +0 -0
  46. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/execution_log.py +0 -0
  47. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/progress.py +0 -0
  48. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_config.py +0 -0
  49. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_profile.py +0 -0
  50. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/report_service.py +0 -0
  51. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/reporting.py +0 -0
  52. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/sampling.py +0 -0
  53. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/sampling_service.py +0 -0
  54. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scan_reuse.py +0 -0
  55. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scan_service.py +0 -0
  56. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/__init__.py +0 -0
  57. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/api.py +0 -0
  58. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/__init__.py +0 -0
  59. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/amount.py +0 -0
  60. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/enumeration.py +0 -0
  61. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/names.py +0 -0
  62. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/number.py +0 -0
  63. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/pattern.py +0 -0
  64. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/percentage.py +0 -0
  65. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/detectors/shape.py +0 -0
  66. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/diagnostics.py +0 -0
  67. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/engine.py +0 -0
  68. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/exposure.py +0 -0
  69. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/field.py +0 -0
  70. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/normalization.py +0 -0
  71. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/observations.py +0 -0
  72. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/paths.py +0 -0
  73. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/__init__.py +0 -0
  74. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/base.py +0 -0
  75. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/csv_reader.py +0 -0
  76. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/readers/json_reader.py +0 -0
  77. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/scanner/technical.py +0 -0
  78. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/static/report.js +0 -0
  79. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst/static/theme.css +0 -0
  80. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/SOURCES.txt +0 -0
  81. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/dependency_links.txt +0 -0
  82. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/entry_points.txt +0 -0
  83. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/requires.txt +0 -0
  84. {tabalyst-0.4.0 → tabalyst-0.4.1}/src/tabalyst.egg-info/top_level.txt +0 -0
  85. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_public_api.py +0 -0
  86. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_reporting.py +0 -0
  87. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_sampling.py +0 -0
  88. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scan_command.py +0 -0
  89. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_amounts.py +0 -0
  90. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_config.py +0 -0
  91. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_csv.py +0 -0
  92. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_email_url.py +0 -0
  93. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_exposure.py +0 -0
  94. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_paths.py +0 -0
  95. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_phone.py +0 -0
  96. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_postal.py +0 -0
  97. {tabalyst-0.4.0 → tabalyst-0.4.1}/tests/test_scanner_uuid_ip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabalyst
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Open-source toolkit for understanding and working with unfamiliar data.
5
5
  Author: Gregory Borelli
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tabalyst"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Open-source toolkit for understanding and working with unfamiliar data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -378,7 +378,7 @@ class DatasetProfile(ResultModel):
378
378
 
379
379
  class ReportProfile(ResultModel):
380
380
  format_version: Literal["0.1.0a"] = "0.1.0a"
381
- format_revision: Literal[6] = 6
381
+ format_revision: Literal[7] = 7
382
382
  generated_at: datetime
383
383
  processing_seconds: FiniteFloat
384
384
  source: SourceInfo
@@ -52,6 +52,9 @@ MAX_PREVIEW_RECORDS = 1_000
52
52
  MAX_PATTERNS = 200
53
53
  MAX_PATTERN_LENGTH = 1_000
54
54
  MAX_PATTERN_INPUT_LENGTH = 10_000
55
+ # A streamed field keeps its warm-up values: the warm-up is bounded.
56
+ MAX_WARMUP_VALUES = 1_000_000
57
+ MAX_PROBE_INTERVAL = 1_000_000
55
58
 
56
59
  MissingCategory = Literal["absent", "null", "empty", "blank", "marker"]
57
60
  ScalarType = Literal["string", "integer", "number", "boolean"]
@@ -176,6 +179,9 @@ class TypeSettings(_Settings):
176
179
 
177
180
  class DetectionSettings(_Settings):
178
181
  minimum_share: float = Field(default=0.95, gt=0, le=1)
182
+ # Adaptive detection (design 13): 0 keeps detection exhaustive.
183
+ warmup_values: int = Field(default=10_000, ge=0, le=MAX_WARMUP_VALUES)
184
+ probe_interval: int = Field(default=100, ge=0, le=MAX_PROBE_INTERVAL)
179
185
 
180
186
 
181
187
  NumberConvention = Literal["dot", "comma"]
@@ -69,9 +69,15 @@ class DetectorAccumulator:
69
69
  ``add`` receives every tested value with its classification (``None`` when
70
70
  not matched) and its count. In both execution modes it sees the same
71
71
  distinct values, first seen first, with the same total counts.
72
+
73
+ A class whose ``add`` does nothing for unmatched values (``None``) sets
74
+ ``ignores_unmatched`` in its own body: the engine then skips those calls.
75
+ The flag applies only to the class that defines ``add``, so a subclass
76
+ that overrides ``add`` receives every value unless it sets it again.
72
77
  """
73
78
 
74
79
  __slots__ = ()
80
+ ignores_unmatched: ClassVar[bool] = True
75
81
 
76
82
  def add(
77
83
  self, value: str, classification: Classification | None, count: int
@@ -135,6 +141,7 @@ class AmbiguityAccumulator(DetectorAccumulator):
135
141
  """
136
142
 
137
143
  __slots__ = ("candidates", "count", "evidence", "resolution")
144
+ ignores_unmatched = True
138
145
 
139
146
  def __init__(self, readings: list[str], resolution: dict[str, str] | None) -> None:
140
147
  self.count = 0
@@ -51,6 +51,7 @@ class BooleanDetector(Detector):
51
51
 
52
52
  class BooleanAccumulator(DetectorAccumulator):
53
53
  __slots__ = ("false", "true")
54
+ ignores_unmatched = True
54
55
 
55
56
  def __init__(self) -> None:
56
57
  self.true = 0
@@ -113,6 +113,7 @@ class CurrencyDetector(Detector):
113
113
 
114
114
  class CurrencyAccumulator(AmountAccumulator):
115
115
  __slots__ = ("currencies",)
116
+ ignores_unmatched = True
116
117
 
117
118
  def __init__(self, reader: AmountReader) -> None:
118
119
  super().__init__(reader)
@@ -72,6 +72,7 @@ class EmailDetector(Detector):
72
72
 
73
73
  class EmailAccumulator(DetectorAccumulator):
74
74
  __slots__ = ("domains",)
75
+ ignores_unmatched = True
75
76
 
76
77
  def __init__(self, domains: NameCounts) -> None:
77
78
  self.domains = domains
@@ -114,6 +114,7 @@ class IpAddressDetector(Detector):
114
114
 
115
115
  class IpAddressAccumulator(DetectorAccumulator):
116
116
  __slots__ = ("versions",)
117
+ ignores_unmatched = True
117
118
 
118
119
  def __init__(self, versions: tuple[str, ...]) -> None:
119
120
  self.versions = dict.fromkeys(sorted(versions), 0)
@@ -126,6 +126,7 @@ class PhoneDetector(Detector):
126
126
 
127
127
  class PhoneAccumulator(DetectorAccumulator):
128
128
  __slots__ = ("regions",)
129
+ ignores_unmatched = True
129
130
 
130
131
  def __init__(self, regions: tuple[str, ...]) -> None:
131
132
  self.regions = dict.fromkeys(sorted(regions), 0)
@@ -85,6 +85,7 @@ class PostalCodeDetector(Detector):
85
85
 
86
86
  class PostalCodeAccumulator(DetectorAccumulator):
87
87
  __slots__ = ("regions",)
88
+ ignores_unmatched = True
88
89
 
89
90
  def __init__(self, regions: tuple[str, ...]) -> None:
90
91
  self.regions = dict.fromkeys(sorted(regions), 0)
@@ -83,6 +83,7 @@ class QuantityDetector(Detector):
83
83
 
84
84
  class QuantityAccumulator(AmountAccumulator):
85
85
  __slots__ = ("units",)
86
+ ignores_unmatched = True
86
87
 
87
88
  def __init__(self, reader: AmountReader, units: NameCounts) -> None:
88
89
  super().__init__(reader)
@@ -37,6 +37,7 @@ from tabalyst.scanner.detectors.uuid import UuidDetector
37
37
  from tabalyst.scanner.exposure import SHOW, ExposureGate
38
38
  from tabalyst.scanner.measures import UNCONVERTIBLE
39
39
  from tabalyst.scanner.models import (
40
+ Adaptive,
40
41
  Coverage,
41
42
  DetectorComplete,
42
43
  DetectorDisabled,
@@ -70,10 +71,15 @@ BUILT_INS: tuple[type[Detector], ...] = (
70
71
  _NATIVE_SCALARS = ("string", "integer", "number", "boolean")
71
72
  _NO_VALUES = NotApplicable(reason="no_values")
72
73
  _STATES = ("matched", "ambiguous", "invalid", "not_matched")
74
+ # Distinct shapes memoized by a detector set (``DetectorSet._fits``).
75
+ SHAPE_CACHE_SIZE = 4_096
73
76
 
74
77
  # ``report_failure(detector_id, error)`` records a ``detector_failed``
75
78
  # diagnostic and returns its index.
76
79
  FailureReporter = Callable[[str, str], int]
80
+ # ``report_probe(detector_id, count)`` records a ``detector_skipped_reacted``
81
+ # warning for ``count`` probed occurrences and returns its index.
82
+ ProbeReporter = Callable[[str, int], int]
77
83
 
78
84
 
79
85
  class _NotEligible:
@@ -88,6 +94,22 @@ class _NotEligible:
88
94
  NOT_ELIGIBLE = _NotEligible()
89
95
 
90
96
 
97
+ class _Skipped:
98
+ """Adaptive detection did not test the value (design 13)."""
99
+
100
+ __slots__ = ()
101
+
102
+ def __repr__(self) -> str:
103
+ return "SKIPPED"
104
+
105
+
106
+ SKIPPED = _Skipped()
107
+ # The plan of a skipped detector, for the native types it accepts.
108
+ _SKIP = ("skip",)
109
+ # Per native type, one plan per active detector (``DetectorSet._plans``).
110
+ Plans = dict[str, tuple[object, ...]]
111
+
112
+
91
113
  class DetectorRegistry:
92
114
  """Detector classes by ``id``, in registration order."""
93
115
 
@@ -121,6 +143,15 @@ def default_registry() -> DetectorRegistry:
121
143
  return DetectorRegistry(BUILT_INS)
122
144
 
123
145
 
146
+ def _ignores_unmatched(accumulator: object) -> bool:
147
+ """Whether the class that defines ``accumulator.add`` declares, in its own
148
+ body, that ``add`` does nothing for unmatched values."""
149
+ for cls in type(accumulator).__mro__:
150
+ if "add" in cls.__dict__:
151
+ return cls.__dict__.get("ignores_unmatched", False) is True
152
+ return False
153
+
154
+
124
155
  class DetectorTally:
125
156
  """Coverage, formats and evidence of one detector on one field."""
126
157
 
@@ -138,6 +169,10 @@ class DetectorTally:
138
169
  "max_length",
139
170
  "not_matched",
140
171
  "not_tested",
172
+ "skipped",
173
+ "skipped_after",
174
+ "unmatched_examples",
175
+ "unmatched_to_accumulator",
141
176
  )
142
177
 
143
178
  def __init__(self, detector: Detector, max_examples: int, max_length: int) -> None:
@@ -150,22 +185,49 @@ class DetectorTally:
150
185
  self.invalid = 0
151
186
  self.not_matched = 0
152
187
  self.not_tested = 0
188
+ # Adaptive detection: values not tested, and the warm-up size once
189
+ # the detector is skipped for the field.
190
+ self.skipped = 0
191
+ self.skipped_after: int | None = None
153
192
  self.formats: dict[str, int] = {}
154
193
  self.examples: dict[str, list[str]] = {state: [] for state in _STATES}
155
194
  self.error: str | None = None
195
+ self.unmatched_examples = self.examples["not_matched"]
196
+ self.unmatched_to_accumulator = True
156
197
  try:
157
198
  self.accumulator = detector.accumulator()
158
199
  except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
159
200
  self.error = type(exc).__name__
201
+ else:
202
+ self.unmatched_to_accumulator = not _ignores_unmatched(self.accumulator)
160
203
 
161
204
  def add(self, value: str, classification: object, count: int) -> None:
205
+ if classification is None: # the most frequent case
206
+ if self.error is not None:
207
+ return
208
+ self.eligible += count
209
+ self.not_matched += count
210
+ examples = self.unmatched_examples
211
+ if (
212
+ len(examples) < self.max_examples
213
+ and len(value) <= self.max_length
214
+ and value not in examples
215
+ ):
216
+ examples.append(value)
217
+ if self.unmatched_to_accumulator:
218
+ try:
219
+ self.accumulator.add(value, None, count)
220
+ except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
221
+ self.error = type(exc).__name__
222
+ return
162
223
  if classification is NOT_ELIGIBLE or self.error is not None:
163
224
  return
164
225
  self.eligible += count
165
- if classification is None: # the most frequent case
166
- state = "not_matched"
167
- self.not_matched += count
168
- elif classification is NOT_TESTED:
226
+ if classification is SKIPPED:
227
+ self.not_tested += count
228
+ self.skipped += count
229
+ return
230
+ if classification is NOT_TESTED:
169
231
  self.not_tested += count
170
232
  return
171
233
  elif type(classification) is DetectorFailure:
@@ -197,12 +259,23 @@ class DetectorTally:
197
259
  except Exception as exc: # noqa: BLE001 - detector code is isolated (CA19)
198
260
  self.error = type(exc).__name__
199
261
 
262
+ def reacted(self) -> bool:
263
+ """A value matched, was ambiguous or invalid, or the detector failed."""
264
+ return bool(
265
+ self.matched or self.ambiguous or self.invalid or self.error is not None
266
+ )
267
+
200
268
  def exposes_sensitive_values(self) -> bool:
201
269
  """A sensitive detector matched a value of the field, or failed on it,
202
270
  which cannot prove that nothing matched (design 12.8)."""
203
271
  return self.detector.sensitive and (self.matched > 0 or self.error is not None)
204
272
 
205
- def result(self, report_failure: FailureReporter, gate: ExposureGate = SHOW):
273
+ def result(
274
+ self,
275
+ report_failure: FailureReporter,
276
+ gate: ExposureGate = SHOW,
277
+ report_probe: ProbeReporter | None = None,
278
+ ):
206
279
  detector = self.detector
207
280
  identity = {"id": detector.id, "version": detector.version}
208
281
  if self.error is None and self.eligible:
@@ -229,6 +302,19 @@ class DetectorTally:
229
302
  moved = (examples["matched"] + examples["not_matched"])[: self.max_examples]
230
303
  examples = {**examples, "matched": [], "not_matched": moved}
231
304
  tested = self.eligible - self.not_tested
305
+ adaptive = None
306
+ if self.skipped_after is not None:
307
+ # The warm-up saw no reaction: every reaction comes from a probe.
308
+ reactions = matched + self.ambiguous + self.invalid
309
+ adaptive = Adaptive(
310
+ skipped_after=self.skipped_after,
311
+ not_tested=self.skipped,
312
+ diagnostic=(
313
+ report_probe(detector.id, reactions)
314
+ if reactions and report_probe is not None
315
+ else None
316
+ ),
317
+ )
232
318
  return DetectorComplete(
233
319
  **identity,
234
320
  coverage=Coverage(
@@ -252,6 +338,7 @@ class DetectorTally:
252
338
  **{state: gate.examples(values) for state, values in examples.items()}
253
339
  ),
254
340
  details=details,
341
+ adaptive=adaptive,
255
342
  )
256
343
 
257
344
 
@@ -263,16 +350,20 @@ class DetectorSet:
263
350
  """The detectors of one scan, in registry order."""
264
351
 
265
352
  __slots__ = (
353
+ "_adaptive",
266
354
  "_date",
267
355
  "_exposure",
268
356
  "_idle",
269
357
  "_number",
270
358
  "_plans",
359
+ "_shape_cache",
271
360
  "active",
272
361
  "entries",
273
362
  "max_examples",
274
363
  "max_length",
275
364
  "minimum_share",
365
+ "probe_interval",
366
+ "warmup_values",
276
367
  )
277
368
 
278
369
  def __init__(self, registry: DetectorRegistry, config: ScanConfig) -> None:
@@ -300,6 +391,13 @@ class DetectorSet:
300
391
  self.max_examples = config.limits.max_evidence_examples
301
392
  self.max_length = config.limits.max_stored_value_length
302
393
  self.minimum_share = exact_share(config.detection.minimum_share)
394
+ self.warmup_values = config.detection.warmup_values
395
+ self.probe_interval = config.detection.probe_interval
396
+ # The number and date detectors give every value its technical type
397
+ # family (design 9.7): adaptive detection never skips them.
398
+ self._adaptive = tuple(
399
+ i not in (self._number, self._date) for i in range(len(active))
400
+ )
303
401
  self._exposure = ExposureGate(config.exposure.sensitive_values)
304
402
  # Native types that no active detector accepts need no work.
305
403
  self._idle = {
@@ -318,30 +416,37 @@ class DetectorSet:
318
416
  )
319
417
  for native_type in _NATIVE_SCALARS
320
418
  }
419
+ self._shape_cache: dict[str, tuple[bool, ...]] = {}
321
420
 
322
421
  def versions(self) -> dict[str, int]:
323
422
  return {detector.id: detector.version for detector in self.active}
324
423
 
325
- def classify(self, native_type: str, text: str) -> tuple[object, ...]:
424
+ def classify(
425
+ self, native_type: str, text: str, plans: Plans | None = None
426
+ ) -> tuple[object, ...]:
326
427
  """One entry per active detector: a classification, ``None`` (not
327
- matched), ``NOT_TESTED``, ``NOT_ELIGIBLE`` or a ``DetectorFailure``."""
428
+ matched), ``NOT_TESTED``, ``SKIPPED``, ``NOT_ELIGIBLE`` or a
429
+ ``DetectorFailure``. ``plans`` skips detectors (``skipping``)."""
328
430
  idle = self._idle.get(native_type)
329
431
  if idle is not None:
330
432
  return idle
331
433
  results: list[object] = []
332
- value_shape = None
333
- for plan in self._plans[native_type]:
434
+ fits = None
435
+ for i, plan in enumerate((plans or self._plans)[native_type]):
334
436
  if plan is None:
335
437
  results.append(NOT_ELIGIBLE)
336
438
  continue
439
+ if plan is _SKIP:
440
+ results.append(SKIPPED)
441
+ continue
337
442
  classify, cap, pattern = plan
338
443
  if cap is not None and len(text) > cap:
339
444
  results.append(NOT_TESTED)
340
445
  continue
341
446
  if pattern is not None:
342
- if value_shape is None:
343
- value_shape = shape(text)
344
- if pattern.fullmatch(value_shape) is None:
447
+ if fits is None:
448
+ fits = self._fits(shape(text))
449
+ if not fits[i]:
345
450
  results.append(None)
346
451
  continue
347
452
  try:
@@ -354,11 +459,52 @@ class DetectorSet:
354
459
  results.append(found)
355
460
  return tuple(results)
356
461
 
357
- def analyze(self, text: str) -> tuple[tuple[object, ...], object, bool, str]:
462
+ def _fits(self, value_shape: str) -> tuple[bool, ...]:
463
+ """Whether ``value_shape`` fits the shapes of each active detector.
464
+
465
+ Shapes repeat across values far more than values do: the matches are
466
+ memoized per shape, in a bounded cache.
467
+ """
468
+ cache = self._shape_cache
469
+ fits = cache.get(value_shape)
470
+ if fits is None:
471
+ if len(cache) >= SHAPE_CACHE_SIZE:
472
+ cache.clear()
473
+ fits = cache[value_shape] = tuple(
474
+ detector.shapes is None
475
+ or detector.shapes.fullmatch(value_shape) is not None
476
+ for detector in self.active
477
+ )
478
+ return fits
479
+
480
+ def skipping(self, tallies: list[DetectorTally]) -> Plans | None:
481
+ """Plans that skip, for the rest of a field, the adaptive detectors
482
+ that reacted to none of its warm-up values; ``None`` when every
483
+ detector reacted (design 13). Skipped tallies record the warm-up."""
484
+ skip = [
485
+ adaptive and not tally.reacted()
486
+ for adaptive, tally in zip(self._adaptive, tallies)
487
+ ]
488
+ if not any(skip):
489
+ return None
490
+ for tally, skipped in zip(tallies, skip):
491
+ if skipped:
492
+ tally.skipped_after = self.warmup_values
493
+ return {
494
+ native_type: tuple(
495
+ _SKIP if skipped and plan is not None else plan
496
+ for plan, skipped in zip(plans, skip)
497
+ )
498
+ for native_type, plans in self._plans.items()
499
+ }
500
+
501
+ def analyze(
502
+ self, text: str, plans: Plans | None = None
503
+ ) -> tuple[tuple[object, ...], object, bool, str]:
358
504
  """Classifications of an analytical string, the number the number
359
505
  detector reads without ambiguity, whether it has a decimal form, and
360
506
  the technical type family."""
361
- classifications = self.classify("string", text)
507
+ classifications = self.classify("string", text, plans)
362
508
  number, decimal_form = None, False
363
509
  if self._number is not None:
364
510
  found = classifications[self._number]
@@ -389,6 +535,7 @@ class DetectorSet:
389
535
  tallies: list[DetectorTally] | None,
390
536
  report_failure: FailureReporter,
391
537
  gate: ExposureGate = SHOW,
538
+ report_probe: ProbeReporter | None = None,
392
539
  ) -> list:
393
540
  """Every registered detector in order; ``tallies`` is ``None`` for a
394
541
  field without analyzable values."""
@@ -409,7 +556,9 @@ class DetectorSet:
409
556
  )
410
557
  )
411
558
  else:
412
- results.append(tallies[position].result(report_failure, gate))
559
+ results.append(
560
+ tallies[position].result(report_failure, gate, report_probe)
561
+ )
413
562
  position += 1
414
563
  return results
415
564
 
@@ -347,6 +347,7 @@ class DateAccumulator(AmbiguityAccumulator):
347
347
  9.6): ambiguous values are counted, never compared."""
348
348
 
349
349
  __slots__ = ("kinds", "unresolved")
350
+ ignores_unmatched = True
350
351
 
351
352
  def __init__(self, readings: list[str], resolution: dict[str, str] | None) -> None:
352
353
  super().__init__(readings, resolution)
@@ -130,6 +130,7 @@ class UrlDetector(Detector):
130
130
 
131
131
  class UrlAccumulator(DetectorAccumulator):
132
132
  __slots__ = ("hosts",)
133
+ ignores_unmatched = True
133
134
 
134
135
  def __init__(self, hosts: NameCounts) -> None:
135
136
  self.hosts = hosts
@@ -83,6 +83,7 @@ class UuidDetector(Detector):
83
83
 
84
84
  class UuidAccumulator(DetectorAccumulator):
85
85
  __slots__ = ("versions",)
86
+ ignores_unmatched = True
86
87
 
87
88
  def __init__(self) -> None:
88
89
  self.versions: Counter[str] = Counter()
@@ -37,7 +37,7 @@ from tabalyst.scanner.models import (
37
37
  from tabalyst.scanner.normalization import ANALYTICAL_STAGE, STAGES, Normalizer
38
38
 
39
39
  if TYPE_CHECKING: # detectors import this module
40
- from tabalyst.scanner.detectors.registry import DetectorSet
40
+ from tabalyst.scanner.detectors.registry import DetectorSet, Plans
41
41
 
42
42
  # Exact decimal arithmetic: any rounding or exponent outside the range makes
43
43
  # the numeric envelope ``limited`` with reason ``precision``.
@@ -151,11 +151,14 @@ def decimal_or_unconvertible(text: str) -> Decimal | _Unconvertible:
151
151
  return UNCONVERTIBLE
152
152
 
153
153
 
154
- def string_facts(raw: str, normalizer: Normalizer, detectors: DetectorSet) -> Facts:
155
- """Facts of a content string; detectors see its analytical value."""
154
+ def string_facts(
155
+ raw: str, normalizer: Normalizer, detectors: DetectorSet, plans: Plans | None = None
156
+ ) -> Facts:
157
+ """Facts of a content string; detectors see its analytical value, and
158
+ ``plans`` may skip some of them (design 13)."""
156
159
  forms, changes = normalizer.run(raw)
157
160
  text = forms[ANALYTICAL_STAGE]
158
- classifications, number, decimal_form, family = detectors.analyze(text)
161
+ classifications, number, decimal_form, family = detectors.analyze(text, plans)
159
162
  return (
160
163
  "string",
161
164
  text,
@@ -177,11 +180,13 @@ def canonical_text(native_type: str, value: object) -> str:
177
180
  return str(value)
178
181
 
179
182
 
180
- def scalar_facts(native_type: str, value: object, detectors: DetectorSet) -> Facts:
183
+ def scalar_facts(
184
+ native_type: str, value: object, detectors: DetectorSet, plans: Plans | None = None
185
+ ) -> Facts:
181
186
  """Facts of an integer, number (``Decimal``) or boolean; detectors that
182
187
  accept its native type see its canonical text."""
183
188
  text = canonical_text(native_type, value)
184
- classifications = detectors.classify(native_type, text)
189
+ classifications = detectors.classify(native_type, text, plans)
185
190
  if native_type == "boolean":
186
191
  return (native_type, text, 0, -1, None, False, 0, None, classifications, "boolean")
187
192
  return (
@@ -18,7 +18,7 @@ T = TypeVar("T")
18
18
 
19
19
  FORMAT = "tabalyst.scan"
20
20
  FORMAT_VERSION = "0.1.0a"
21
- FORMAT_REVISION = 2
21
+ FORMAT_REVISION = 3
22
22
 
23
23
 
24
24
  class ScanModel(BaseModel):
@@ -341,12 +341,23 @@ class _Detector(ScanModel):
341
341
  version: int
342
342
 
343
343
 
344
+ class Adaptive(ScanModel):
345
+ """Design 13: the detector reacted to none of the first ``skipped_after``
346
+ distinct values of the field, so later values other than probes were not
347
+ tested. ``diagnostic`` points to the warning raised when a probe reacted."""
348
+
349
+ skipped_after: int
350
+ not_tested: int
351
+ diagnostic: int | None
352
+
353
+
344
354
  class DetectorComplete(_Detector):
345
355
  status: Literal["complete"] = "complete"
346
356
  coverage: Coverage
347
357
  formats: list[FormatCount]
348
358
  evidence: Evidence
349
359
  details: dict[str, Any]
360
+ adaptive: Adaptive | None
350
361
 
351
362
 
352
363
  class DetectorNotApplicable(_Detector):