tabagent 0.1.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.
Files changed (113) hide show
  1. tabagent/__init__.py +57 -0
  2. tabagent/adapters/__init__.py +68 -0
  3. tabagent/adapters/common.py +138 -0
  4. tabagent/adapters/cuga.py +623 -0
  5. tabagent/adapters/detect.py +182 -0
  6. tabagent/adapters/live.py +111 -0
  7. tabagent/adapters/logfiles.py +728 -0
  8. tabagent/adapters/openai_messages.py +672 -0
  9. tabagent/adapters/tau2.py +195 -0
  10. tabagent/agent.py +219 -0
  11. tabagent/api.py +1016 -0
  12. tabagent/artifact.py +262 -0
  13. tabagent/auto.py +260 -0
  14. tabagent/baselines.py +62 -0
  15. tabagent/cards/__init__.py +1 -0
  16. tabagent/cards/appworld_subagent_routing.yaml +77 -0
  17. tabagent/cards/llmrouterbench_model_routing.yaml +113 -0
  18. tabagent/cards/mind2web_element.yaml +88 -0
  19. tabagent/cards/mind2web_operation.yaml +55 -0
  20. tabagent/cards/skillsbench_skill_gating.yaml +57 -0
  21. tabagent/cards/skillsbench_skill_gating_enriched.yaml +81 -0
  22. tabagent/cards/skillsbench_skill_select.yaml +61 -0
  23. tabagent/cards/skillsbench_skill_select_noprior.yaml +53 -0
  24. tabagent/cards/tau2_domain_shortlisting.yaml +85 -0
  25. tabagent/cards/tau2_tool_shortlisting.yaml +58 -0
  26. tabagent/cli.py +851 -0
  27. tabagent/datasets/__init__.py +138 -0
  28. tabagent/datasets/_core.py +171 -0
  29. tabagent/datasets/appworld.py +207 -0
  30. tabagent/datasets/folds/appworld_subagent_routing.json +590 -0
  31. tabagent/datasets/folds/llmrouterbench_tau2.json +283 -0
  32. tabagent/datasets/folds/mind2web_element_150.json +155 -0
  33. tabagent/datasets/folds/mind2web_groupkfold5.json +1014 -0
  34. tabagent/datasets/folds/mind2web_split.json +1014 -0
  35. tabagent/datasets/folds/skillsbench_skill_gating.json +89 -0
  36. tabagent/datasets/folds/skillsbench_skill_gating_enriched.json +92 -0
  37. tabagent/datasets/folds/skillsbench_skill_select.json +92 -0
  38. tabagent/datasets/folds/tau2_agents.json +599 -0
  39. tabagent/datasets/folds/tau2_airline.json +29 -0
  40. tabagent/datasets/folds/tau2_all_domains.json +144 -0
  41. tabagent/datasets/folds/tau2_retail.json +59 -0
  42. tabagent/datasets/folds/tau2_telecom.json +66 -0
  43. tabagent/datasets/llmrouterbench.py +249 -0
  44. tabagent/datasets/manifests/llmrouterbench_tau2_dense.json +102 -0
  45. tabagent/datasets/manifests/mind2web_train.json +71 -0
  46. tabagent/datasets/manifests/skillsbench_official_selected.json +328 -0
  47. tabagent/datasets/manifests/tau2_official_final.json +185 -0
  48. tabagent/datasets/manifests/tau2_sft_final.json +17 -0
  49. tabagent/datasets/mind2web.py +342 -0
  50. tabagent/datasets/skillsbench.py +207 -0
  51. tabagent/datasets/tau2.py +361 -0
  52. tabagent/defaults.py +23 -0
  53. tabagent/drift.py +701 -0
  54. tabagent/evaluation/__init__.py +36 -0
  55. tabagent/evaluation/cv.py +220 -0
  56. tabagent/evaluation/discovery.py +320 -0
  57. tabagent/evaluation/metrics.py +266 -0
  58. tabagent/evaluation/stats.py +116 -0
  59. tabagent/hub.py +44 -0
  60. tabagent/integrations/__init__.py +1 -0
  61. tabagent/integrations/cuga.py +186 -0
  62. tabagent/integrations/openai_tools.py +232 -0
  63. tabagent/prometheus.py +196 -0
  64. tabagent/py.typed +0 -0
  65. tabagent/report.py +237 -0
  66. tabagent/scanner.py +1113 -0
  67. tabagent/serve.py +873 -0
  68. tabagent/sklearn.py +353 -0
  69. tabagent/tabhead.py +427 -0
  70. tabagent/table.py +178 -0
  71. tabagent/tabschema/__init__.py +22 -0
  72. tabagent/tabschema/agents.py +1304 -0
  73. tabagent/tabschema/audit.py +408 -0
  74. tabagent/tabschema/card.py +392 -0
  75. tabagent/tabschema/checks.py +307 -0
  76. tabagent/tabschema/compile.py +1811 -0
  77. tabagent/tabschema/estimate.py +326 -0
  78. tabagent/tabschema/features.py +114 -0
  79. tabagent/tabschema/fitted.py +474 -0
  80. tabagent/tabschema/judges.py +203 -0
  81. tabagent/tabschema/llm.py +908 -0
  82. tabagent/tabschema/orchestrator.py +1823 -0
  83. tabagent/tabschema/prompts/__init__.py +80 -0
  84. tabagent/tabschema/prompts/code_extractor.txt +93 -0
  85. tabagent/tabschema/prompts/code_validator.txt +39 -0
  86. tabagent/tabschema/prompts/feature_aggregator.txt +37 -0
  87. tabagent/tabschema/prompts/feature_analyzer.txt +35 -0
  88. tabagent/tabschema/prompts/judge_generality.txt +51 -0
  89. tabagent/tabschema/prompts/judge_impact.txt +52 -0
  90. tabagent/tabschema/prompts/judge_relevance.txt +49 -0
  91. tabagent/tabschema/prompts/meta_judge.txt +65 -0
  92. tabagent/tabschema/prompts/output_auditor.txt +34 -0
  93. tabagent/tabschema/prompts/tabagent_analyzer_candidates.txt +6 -0
  94. tabagent/tabschema/prompts/tabagent_auditor_candidates.txt +5 -0
  95. tabagent/tabschema/prompts/tabagent_candidate_note.txt +3 -0
  96. tabagent/tabschema/prompts/tabagent_extractor.txt +3 -0
  97. tabagent/tabschema/prompts/tabagent_extractor_candidates.txt +9 -0
  98. tabagent/tabschema/prompts/tabagent_validator_candidates.txt +9 -0
  99. tabagent/tabschema/runlog.py +626 -0
  100. tabagent/tabschema/runstore.py +267 -0
  101. tabagent/tabschema/sampling.py +143 -0
  102. tabagent/tabschema/sandbox.py +623 -0
  103. tabagent/tabschema/trajectory.py +989 -0
  104. tabagent/tabschema/usefulness.py +193 -0
  105. tabagent/tabschema/verify.py +1303 -0
  106. tabagent/tabschema/warm.py +1073 -0
  107. tabagent/tabsynth.py +344 -0
  108. tabagent-0.1.0.dist-info/METADATA +313 -0
  109. tabagent-0.1.0.dist-info/RECORD +113 -0
  110. tabagent-0.1.0.dist-info/WHEEL +4 -0
  111. tabagent-0.1.0.dist-info/entry_points.txt +2 -0
  112. tabagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  113. tabagent-0.1.0.dist-info/licenses/NOTICE +39 -0
@@ -0,0 +1,1823 @@
1
+ """TabSchema, the FeatureOrchestrator (paper Sec. 3.1; App. D).
2
+
3
+ ``TabSchema.discover`` turns an agent's successful trajectories into a feature card for one decision
4
+ component:
5
+
6
+ 1. **sample** decision points (the component's occurrences in successful runs), one per task first;
7
+ 2. per sampled decision point, in parallel: **FeatureAnalyzer** proposes features; **CodeExtractor** and
8
+ **CodeExecutor** make them executable on that trace (three retries); **CodeValidator** keeps the
9
+ features the executed code implements faithfully from real fields;
10
+ 3. **FeatureAggregator** merges the proposals of all decision points;
11
+ 4. the **Relevance**, **Generality** and **Impact and Uniqueness** judges score them concurrently, and the
12
+ **MetaJudge** accepts, conditionally accepts or rejects each one;
13
+ 5. **compile**: one reusable extractor for the accepted features, run and quality-checked on every
14
+ onboarding decision point;
15
+ 6. the **card**: extractor features (state), candidate name/description/argument hints (schema,
16
+ Sec. 3.3), and dependency statistics (priors, context-candidate similarity) that the card fits on the
17
+ training rows of each fold (Sec. 4.3). For a ranking or multi-label decision the recipe search may add,
18
+ for each numeric per-candidate feature and dependency statistic, its rank and its gap to the best within
19
+ the decision (``card.with_relative``), which a pointwise head cannot see otherwise; the discovered card
20
+ never holds them.
21
+
22
+ For a ranking or multi-label decision, the extractor may also define a per-candidate function,
23
+ ``extract_candidate_features(trajectory_data, candidate)``, whose features describe one candidate in the
24
+ context of the prefix (the release's addition; ``compile``): the FeatureAnalyzer prompt says that features of
25
+ a candidate may be proposed (with three of the decision's candidates, picked label-free:
26
+ ``agents.pick_examples``), the FeatureAggregator, judge and MetaJudge prompts that such features are
27
+ obtainable, the CodeExtractor and CodeValidator prompts get a section on the function, and its columns enter
28
+ the card as schema features computed per (decision, candidate) row (``source: extractor_candidate``).
29
+
30
+ The release adds three checks the paper does not have, between compiling and the card: the compile step's
31
+ **verification** of the values (determinism, outcome and future invariance, robustness; ``verify``) with
32
+ golden tests saved as ``extractor_tests.json``; the **OutputAuditor**, an LLM judge of the values on a sample
33
+ of decision points (``audit``); and a **usefulness** estimate of each feature by grouped cross-validation on
34
+ the onboarding rows (``usefulness``). Each can be turned off in ``TabSchemaConfig``.
35
+
36
+ Discovery runs once, on the onboarding trajectories it is given. The compiled extractor is a stateless
37
+ function of one trajectory prefix, so applying it to held-out decisions leaks nothing; every label-derived
38
+ statistic is fitted by the card inside ``cross_validate``'s training folds. Running discovery itself inside
39
+ each fold means calling ``discover`` on that fold's trajectories.
40
+
41
+ A decision point whose analysis fails (an unusable answer, a content filter, code that never runs) is skipped
42
+ and recorded in ``analysis.json``; the run goes on with the others. A batch of a whole-run stage whose answer
43
+ is unusable is asked again in halves (``agents.in_halves``). A provider error that is transient is retried
44
+ with backoff by the stage (``llm.RetryPolicy``); one that persists stops the run (``LLMUnavailable``).
45
+
46
+ ``run.json`` in the run directory follows the run as it progresses: each stage's status, timing, progress,
47
+ output, drops, failures and LLM cost (``tabagent.tabschema.runlog``; ``tabagent runs show``). A run that stops
48
+ (a spent budget, a provider that stays down, an interruption) or fails (no feature accepted, an extractor that
49
+ cannot be compiled) leaves a partial ``report.md``, ``run.json`` and every paid answer in its run directory:
50
+ rerunning with that directory resumes, every answer it holds costs nothing, and the resumed run produces the
51
+ card an uninterrupted run would have.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import dataclasses
57
+ import difflib
58
+ import json
59
+ import logging
60
+ import math
61
+ import numbers
62
+ import time
63
+ from collections import Counter
64
+ from concurrent.futures import ThreadPoolExecutor, as_completed
65
+ from dataclasses import dataclass, field
66
+ from pathlib import Path
67
+ from typing import Any
68
+
69
+ import numpy as np
70
+ import pandas as pd
71
+
72
+ from tabagent.defaults import DEFAULT_SEED
73
+ from tabagent.table import DecisionSpec, DecisionTable
74
+ from tabagent.tabschema import prompts
75
+ from tabagent.tabschema import verify as V
76
+ from tabagent.tabschema.agents import (
77
+ REJECTING,
78
+ STOP,
79
+ VALID,
80
+ VERDICTS,
81
+ CodeExecutor,
82
+ CodeExtractor,
83
+ CodeValidator,
84
+ FeatureAggregator,
85
+ FeatureAnalyzer,
86
+ FeatureSpec,
87
+ LLMFormatError,
88
+ candidate_samples,
89
+ extract_with_retries,
90
+ forget_cause,
91
+ normalize_name,
92
+ pick_examples,
93
+ premerge,
94
+ shutdown_interrupted,
95
+ )
96
+ from tabagent.tabschema.audit import OutputAuditor, run_audit
97
+ from tabagent.tabschema.card import DEPENDENCY_FEATURES, Feature, FeatureCard
98
+ from tabagent.tabschema.compile import (
99
+ CONTEXT_TEXT_COLUMN,
100
+ CompiledExtractor,
101
+ ExtractionError,
102
+ ExtractorRunner,
103
+ appended_texts,
104
+ candidate_lists,
105
+ compile_extractor,
106
+ )
107
+ from tabagent.tabschema.judges import DECISIONS, Judge, MetaJudge, judge_table, run_judges
108
+ from tabagent.tabschema.llm import LLM, BudgetExceeded, CostMeter, LLMUnavailable, auto_llm, status_code
109
+ from tabagent.tabschema.runlog import RunLog, drop_reason, stopped_status
110
+ from tabagent.tabschema.runstore import RunStore, atomic_write
111
+ from tabagent.tabschema.sampling import STRATEGIES, coverage, sample_decision_points
112
+ from tabagent.tabschema.sandbox import SubprocessSandbox
113
+ from tabagent.tabschema.trajectory import (
114
+ CANDIDATE_FUNCTION,
115
+ DecisionPoint,
116
+ GenericJsonAdapter,
117
+ Trajectory,
118
+ candidate_dict,
119
+ union_paths,
120
+ )
121
+ from tabagent.tabschema.usefulness import (
122
+ ALL_LLM,
123
+ DEFAULT_MAX_DECISIONS,
124
+ DEFAULT_MAX_ROWS,
125
+ MIN_DECISIONS,
126
+ feature_groups,
127
+ feature_usefulness,
128
+ )
129
+
130
+ logger = logging.getLogger(__name__)
131
+
132
+ GROUP, DECISION, CANDIDATE, LABEL = "task", "decision_id", "candidate", "chosen"
133
+ #: Columns of the decision table and of the card that an extractor column must not shadow.
134
+ RESERVED = frozenset(
135
+ {
136
+ GROUP,
137
+ DECISION,
138
+ CANDIDATE,
139
+ LABEL,
140
+ "label",
141
+ "candidate_name",
142
+ "candidate_description",
143
+ "candidate_args",
144
+ "context_candidate_lex",
145
+ "context_candidate_sim",
146
+ "candidate_prior",
147
+ }
148
+ )
149
+
150
+
151
+ @dataclass
152
+ class TabSchemaConfig:
153
+ """Settings of a TabSchema discovery run.
154
+
155
+ Parameters
156
+ ----------
157
+ model: the LLM (default: the paper's GPT-5.4 on OpenAI, or the provider whose key is in the environment).
158
+ n_analyze: decision points the FeatureAnalyzer reads.
159
+ sample_seed: seed of that sample.
160
+ sample_strategy: how the sample is drawn (see ``tabagent.tabschema.sampling``): ``coverage`` (one per
161
+ task first, covering the sources, positions in the run, prefix lengths and labels of the
162
+ onboarding data) or ``tasks`` (one per task, the first decision of each run).
163
+ max_code_retries: CodeExecutor retries with the error fed back (three, App. D.1).
164
+ json_retries: re-asks when an answer is not parseable JSON.
165
+ accept: MetaJudge outcomes compiled into the card: accepted and conditionally accepted features by
166
+ default. ``("accept",)`` keeps only accepted ones. On the next-tool-call decision of public τ²-bench
167
+ logs, compiling the conditional features too raised head P@R by 0.066 and 0.029 in two discovery runs
168
+ (10 and 20 analyzed decision points); adding the rejected ones changed it by under 0.004 more.
169
+ compile_min_success: share of onboarding decision points the compiled extractor must run on.
170
+ max_null_rate: extractor columns emptier than this are dropped.
171
+ max_columns_per_feature, max_extractor_columns: caps on the columns one planned feature expands into
172
+ (code that returns a dict per row, such as word counts, gets one column per key) and on the
173
+ extractor's columns in all; past them the columns that vary most across decision points are kept.
174
+ On many decision points memory caps the columns other than presence flags further, to
175
+ ``max(64, 20,000,000 / decision points)`` (512 at 39,000 points, 200 at 100,000); presence flags are
176
+ decided from statistics over every point and only ever capped by these two; a compile note says when
177
+ the memory cap binds.
178
+ compile_repair: when the CodeValidator flags accepted features in the compiled code, ask the CodeExtractor
179
+ once more with its objections (the paper's retry prompt), and keep the new code only if it runs and
180
+ fewer features are flagged.
181
+ drop_redundant_text: drop a compiled text column that duplicates another kept text column (the appended
182
+ context text included) word for word on every onboarding decision point: the same lowercased tokens,
183
+ numbers and order, so a text and the list of its tokens collapse into one. Overlap, containment or
184
+ the same words in another order never drop a column. Of duplicates a categorical form is kept over a
185
+ text one. Within the columns of one planned feature, a column that restates another in a derived form
186
+ on every row (its tokens, bag of words or n-grams as a JSON list or dict, or the same words in another
187
+ order) is dropped too: the head represents text itself.
188
+ dependency_features: dependency statistics added to the card. ``co_usage`` is added when an extractor
189
+ column lists earlier candidates (``co_usage_context``).
190
+ pair_embeddings: add the e5 context-candidate similarity; None adds it when the embed extra is installed.
191
+ workers: parallel analyzer calls.
192
+ timeout_s: sandbox time limit per trajectory.
193
+ max_cost_usd: LLM budget; needs a price for the model in ``prices``.
194
+ prices: ``{model: (USD per 1M input tokens, USD per 1M output tokens)}``.
195
+ max_prompt_chars: a decision-point prefix longer than this (as JSON) reaches the FeatureAnalyzer with its
196
+ longest strings clipped.
197
+ stage_models: a model per stage, overriding ``model``: keys are stage roles (``analyzer``, ``extractor``,
198
+ ``validator``, ``aggregator``, ``meta_judge``, ``judge_relevance``, ...) or ``judges`` for all three
199
+ judges. For example ``{"extractor": "azure/gpt-6-sol"}`` when a provider's content filter stops one
200
+ model's code answers. Every model needs a price in ``prices`` when ``max_cost_usd`` is set.
201
+ fallback_model: asked once, for any stage, when the provider's content filter stops an answer.
202
+ context_text: append to the extractor a fixed, non-LLM function that returns the decision's context as
203
+ text (``context_text``), so every card has a context text for the context-candidate similarity
204
+ features, TabSynth and the BM25 baseline, even when the LLM's features are all flags and counts.
205
+ last_step_text: append to the extractor a fixed, non-LLM function that returns the text of the prefix's
206
+ last step (``last_step_text``): the step that handed control to the decision, which the context text
207
+ can dilute among long payloads. It is a context text too (TabSynth, the context-candidate
208
+ similarities).
209
+ positional_guard: for a decision with candidates, ask the CodeExtractor to compute in its per-candidate
210
+ function the features it wrote as columns that index the candidates by position
211
+ (``compile.positional_columns``, decided from the columns' values), and drop those columns once it
212
+ does.
213
+ relative_features: let the recipe search (``auto.select``) try, for a ranking or multi-label decision,
214
+ each numeric per-candidate feature's and dependency statistic's rank and gap to the best within the
215
+ decision (``card.with_relative``) as a challenger, kept only when it scores strictly better in the
216
+ inner validation. The discovered card never holds them, so the usefulness estimate and a
217
+ ``TabAgent`` built on the card directly do not use them; the saved head records the choice.
218
+
219
+ The release's checks beyond the paper (see ``docs/onboarding.md``, "How the generated code is verified"):
220
+
221
+ verify: run the compiled extractor, sandboxed, on ``verify_samples`` decision points and drop the columns
222
+ that are nondeterministic or change when outcome fields or the run's future change; report how it
223
+ copes with short and incomplete prefixes (``tabagent.tabschema.verify``).
224
+ verify_samples: decision points of those checks (coverage-sampled).
225
+ verify_adapter: check, before any LLM call, that the adapter's prefixes do not change with the run's
226
+ future (``verify.FutureCheck``); a prefix that does stops discovery with ``PrefixLeakError``. Turning
227
+ it off (or ``verify``) skips the check with a warning; an adapter whose prefixes legitimately hold the
228
+ decision step's own input (its prompt) declares those keys in ``decision_input_keys`` instead.
229
+ goldens: golden tests recorded in ``extractor_tests.json`` and saved with the head (0: none).
230
+ audit: ask the OutputAuditor (stage ``auditor``) whether the extracted values are correct on
231
+ ``audit_samples`` decision points, and drop features judged incorrect on more than
232
+ ``audit_max_incorrect`` of the points (at least 3) where they were judged
233
+ (``tabagent.tabschema.audit``).
234
+ usefulness: estimate each feature's contribution by drop-column grouped cross-validation on the onboarding
235
+ rows, on at most ``usefulness_max_decisions`` decision points (``tabagent.tabschema.usefulness``); a
236
+ report only, unless ``prune_harmful`` also drops the features whose removal clearly helps.
237
+
238
+ Operations:
239
+
240
+ llm_retries: retries of an LLM call that fails transiently (a rate limit, a dropped connection, a server
241
+ error, an empty answer), with exponential backoff that honors the provider's ``Retry-After``; past
242
+ them the run stops with ``LLMUnavailable``, resumable from its run directory. A call that times out
243
+ is retried once, then handled like an answer cut at the output limit.
244
+ request_timeout_s: seconds one LLM request may take, for the backends ``auto_llm`` builds; None scales it
245
+ with the stage's output limit: ``max(300, 120 + max_tokens / 40)``, room for the longest answer at 40
246
+ tokens per second (520 s for the per-decision-point stages' 16,000 tokens, 920 s for the whole-run
247
+ stages' 32,000).
248
+
249
+ Every field is checked when the config is made (types and ranges), with a message naming the field;
250
+ :meth:`from_dict` builds one from a mapping (a JSON or YAML file) and names unknown keys.
251
+ """
252
+
253
+ model: str | None = None
254
+ n_analyze: int = 20
255
+ sample_seed: int = DEFAULT_SEED
256
+ sample_strategy: str = "coverage"
257
+ max_code_retries: int = 3
258
+ json_retries: int = 1
259
+ accept: tuple[str, ...] = ("accept", "conditional")
260
+ compile_min_success: float = 0.95
261
+ max_null_rate: float = 0.5
262
+ max_columns_per_feature: int = 64
263
+ max_extractor_columns: int = 512
264
+ compile_repair: bool = True
265
+ drop_redundant_text: bool = True
266
+ dependency_features: tuple[str, ...] = (
267
+ "candidate_prior",
268
+ "context_candidate_lex",
269
+ "context_candidate_sim",
270
+ "co_usage",
271
+ )
272
+ pair_embeddings: bool | None = None
273
+ workers: int = 8
274
+ timeout_s: float = 30
275
+ max_cost_usd: float | None = None
276
+ prices: dict[str, tuple[float, float]] = field(default_factory=dict)
277
+ max_prompt_chars: int = 400_000
278
+ stage_models: dict[str, str] = field(default_factory=dict)
279
+ fallback_model: str | None = None
280
+ context_text: bool = True
281
+ last_step_text: bool = True
282
+ positional_guard: bool = True
283
+ relative_features: bool = True
284
+ verify: bool = True
285
+ verify_adapter: bool = True
286
+ verify_samples: int = V.DEFAULT_SAMPLES
287
+ goldens: int = V.DEFAULT_GOLDENS
288
+ audit: bool = True
289
+ audit_samples: int = 12
290
+ audit_max_incorrect: float = 0.25
291
+ usefulness: bool = True
292
+ prune_harmful: bool = False
293
+ usefulness_max_decisions: int = DEFAULT_MAX_DECISIONS
294
+ usefulness_max_rows: int = DEFAULT_MAX_ROWS
295
+ llm_retries: int = 5
296
+ request_timeout_s: float | None = None
297
+
298
+ def __post_init__(self) -> None:
299
+ _validate(self)
300
+
301
+ @classmethod
302
+ def from_dict(cls, d: dict[str, Any]) -> TabSchemaConfig:
303
+ """A config from a mapping (a JSON or YAML file's content), checked like any config; an unknown key is
304
+ an error that suggests the closest field name."""
305
+ if not isinstance(d, dict):
306
+ raise ValueError(
307
+ f"a TabSchemaConfig is a mapping of field names to values, not {type(d).__name__}"
308
+ )
309
+ fields = {f.name for f in dataclasses.fields(cls)}
310
+ unknown = sorted(set(d) - fields)
311
+ if unknown:
312
+ hints = []
313
+ for k in unknown:
314
+ close = difflib.get_close_matches(str(k), sorted(fields), n=1)
315
+ hints.append(f"{k!r}" + (f" (did you mean {close[0]!r}?)" if close else ""))
316
+ raise ValueError(f"unknown TabSchemaConfig field(s): {', '.join(hints)}")
317
+ return cls(**d)
318
+
319
+ def to_dict(self) -> dict[str, Any]:
320
+ """The config as JSON-able values (``from_dict`` reads it back)."""
321
+ return {k: list(v) if isinstance(v, tuple) else v for k, v in dataclasses.asdict(self).items()}
322
+
323
+
324
+ STAGE_ROLES = (
325
+ "analyzer",
326
+ "extractor",
327
+ "validator",
328
+ "aggregator",
329
+ "judge_relevance",
330
+ "judge_generality",
331
+ "judge_impact",
332
+ "meta_judge",
333
+ "auditor",
334
+ )
335
+
336
+
337
+ def _timeout(cfg: TabSchemaConfig) -> dict[str, float]:
338
+ """``auto_llm``'s timeout argument for ``cfg`` (none when the request timeout is left to the backend)."""
339
+ return {} if cfg.request_timeout_s is None else {"timeout": cfg.request_timeout_s}
340
+
341
+
342
+ def _is_bool(v: Any) -> bool:
343
+ return isinstance(v, (bool, np.bool_))
344
+
345
+
346
+ def _is_int(v: Any) -> bool:
347
+ return isinstance(v, numbers.Integral) and not _is_bool(v)
348
+
349
+
350
+ def _is_number(v: Any) -> bool:
351
+ return isinstance(v, numbers.Real) and not _is_bool(v) and math.isfinite(float(v))
352
+
353
+
354
+ def _validate(cfg: TabSchemaConfig) -> None:
355
+ """Check every field of ``cfg`` (types and ranges), normalizing lists to tuples; ``ValueError`` names the
356
+ field, the expected value and the one given."""
357
+
358
+ def bad(name: str, expected: str) -> ValueError:
359
+ return ValueError(f"TabSchemaConfig.{name} must be {expected}, not {getattr(cfg, name)!r}")
360
+
361
+ ints = {
362
+ "n_analyze": 1,
363
+ "max_code_retries": 0,
364
+ "json_retries": 0,
365
+ "max_columns_per_feature": 1,
366
+ "max_extractor_columns": 1,
367
+ "workers": 1,
368
+ "max_prompt_chars": 1_000,
369
+ "verify_samples": 1,
370
+ "goldens": 0,
371
+ "audit_samples": 1,
372
+ "usefulness_max_decisions": 2,
373
+ "usefulness_max_rows": 1,
374
+ "llm_retries": 0,
375
+ }
376
+ for name, low in ints.items():
377
+ v = getattr(cfg, name)
378
+ if not _is_int(v) or v < low:
379
+ raise bad(name, f"an integer of at least {low}")
380
+ setattr(cfg, name, int(v)) # numpy integers become Python ones
381
+ if not _is_int(cfg.sample_seed):
382
+ raise bad("sample_seed", "an integer")
383
+ cfg.sample_seed = int(cfg.sample_seed)
384
+ for name in (
385
+ "compile_repair",
386
+ "drop_redundant_text",
387
+ "context_text",
388
+ "last_step_text",
389
+ "positional_guard",
390
+ "relative_features",
391
+ "verify",
392
+ "verify_adapter",
393
+ "audit",
394
+ "usefulness",
395
+ "prune_harmful",
396
+ ):
397
+ if not _is_bool(getattr(cfg, name)):
398
+ raise bad(name, "True or False")
399
+ setattr(cfg, name, bool(getattr(cfg, name)))
400
+ if cfg.pair_embeddings is not None:
401
+ if not _is_bool(cfg.pair_embeddings):
402
+ raise bad("pair_embeddings", "True, False or None")
403
+ cfg.pair_embeddings = bool(cfg.pair_embeddings)
404
+ for name in ("model", "fallback_model"):
405
+ v = getattr(cfg, name)
406
+ if v is not None and (not isinstance(v, str) or not v.strip()):
407
+ raise bad(name, "a model name or None")
408
+ if cfg.sample_strategy not in STRATEGIES:
409
+ raise bad("sample_strategy", f"one of {STRATEGIES}")
410
+ for name, allowed in (("accept", DECISIONS), ("dependency_features", DEPENDENCY_FEATURES)):
411
+ v = getattr(cfg, name)
412
+ if isinstance(v, str) or not isinstance(v, (tuple, list, set, frozenset)):
413
+ raise bad(name, f"a tuple of {allowed}, such as {allowed[:2]}")
414
+ unknown = [x for x in v if x not in allowed]
415
+ if unknown:
416
+ raise ValueError(f"TabSchemaConfig.{name}: unknown value(s) {unknown}; use some of {allowed}")
417
+ setattr(cfg, name, tuple(dict.fromkeys(v)))
418
+ if not cfg.accept:
419
+ raise bad("accept", f"a non-empty tuple of {DECISIONS}")
420
+ if not _is_number(cfg.compile_min_success) or not 0 < cfg.compile_min_success <= 1:
421
+ raise bad("compile_min_success", "a number in (0, 1]")
422
+ if not _is_number(cfg.max_null_rate) or not 0 <= cfg.max_null_rate <= 1:
423
+ raise bad("max_null_rate", "a number in [0, 1]")
424
+ if not _is_number(cfg.audit_max_incorrect) or not 0 <= cfg.audit_max_incorrect < 1:
425
+ raise bad("audit_max_incorrect", "a number in [0, 1)")
426
+ if not _is_number(cfg.timeout_s) or cfg.timeout_s <= 0:
427
+ raise bad("timeout_s", "a positive number of seconds")
428
+ if cfg.max_cost_usd is not None and (not _is_number(cfg.max_cost_usd) or cfg.max_cost_usd < 0):
429
+ raise bad("max_cost_usd", "a non-negative number of USD or None")
430
+ if cfg.request_timeout_s is not None and (
431
+ not _is_number(cfg.request_timeout_s) or cfg.request_timeout_s <= 0
432
+ ):
433
+ raise bad("request_timeout_s", "a positive number of seconds or None")
434
+ for name in ("compile_min_success", "max_null_rate", "audit_max_incorrect", "timeout_s"):
435
+ setattr(cfg, name, float(getattr(cfg, name)))
436
+ for name in ("max_cost_usd", "request_timeout_s"):
437
+ if getattr(cfg, name) is not None:
438
+ setattr(cfg, name, float(getattr(cfg, name)))
439
+ if not isinstance(cfg.prices, dict):
440
+ raise bad("prices", "a mapping {model: (USD per 1M input tokens, USD per 1M output tokens)}")
441
+ prices = {}
442
+ for model, price in cfg.prices.items():
443
+ ok = (
444
+ isinstance(price, (tuple, list))
445
+ and len(price) == 2
446
+ and all(_is_number(x) and x >= 0 for x in price)
447
+ )
448
+ if not isinstance(model, str) or not ok:
449
+ raise ValueError(
450
+ f"TabSchemaConfig.prices[{model!r}] must be (USD per 1M input tokens, USD per 1M output "
451
+ f"tokens), two non-negative numbers, not {price!r}"
452
+ )
453
+ prices[model] = (float(price[0]), float(price[1]))
454
+ cfg.prices = prices
455
+ if not isinstance(cfg.stage_models, dict):
456
+ raise bad("stage_models", "a mapping {stage: model}")
457
+ roles = (*STAGE_ROLES, "judges")
458
+ unknown = sorted(set(cfg.stage_models) - set(roles))
459
+ if unknown:
460
+ raise ValueError(f"stage_models: unknown stages {unknown}; use {list(roles)}")
461
+ for role, model in cfg.stage_models.items():
462
+ if not isinstance(model, str) or not model.strip():
463
+ raise ValueError(f"TabSchemaConfig.stage_models[{role!r}] must be a model name, not {model!r}")
464
+
465
+
466
+ @dataclass
467
+ class DiscoveryReport:
468
+ """What a discovery run did: the sampled and analyzed decision points, the aggregated, judged, compiled
469
+ and dropped features, the release's verification, output audit and usefulness records, the LLM cost and
470
+ the run directory."""
471
+
472
+ target: str
473
+ n_trajectories: int
474
+ n_successful: int
475
+ n_decision_points: int
476
+ analyzed: list[dict[str, Any]] = field(default_factory=list)
477
+ aggregated: list[str] = field(default_factory=list)
478
+ rejected_by_aggregator: list[str] = field(default_factory=list)
479
+ judged: list[dict[str, Any]] = field(default_factory=list)
480
+ compiled: dict[str, str] = field(default_factory=dict)
481
+ dropped: dict[str, str] = field(default_factory=dict)
482
+ cost: dict[str, Any] = field(default_factory=dict)
483
+ run_dir: str = ""
484
+ notes: list[str] = field(default_factory=list)
485
+ verification: dict[str, Any] = field(default_factory=dict)
486
+ audit: dict[str, Any] = field(default_factory=dict)
487
+ usefulness: dict[str, Any] = field(default_factory=dict)
488
+ per_candidate: list[str] = field(default_factory=list)
489
+ not_produced: dict[str, str] = field(default_factory=dict)
490
+
491
+ def to_markdown(self) -> str:
492
+ """The report as Markdown (written to ``discovery.md``)."""
493
+ c = self.cost
494
+ usd = c.get("total_usd")
495
+ unpriced = ", ".join(c.get("unpriced_models") or []) or "the model"
496
+ cost = f"${usd:.2f}" if usd is not None else f"unknown (no price for {unpriced})"
497
+ tokens = f"{c.get('input_tokens', 0):,} input / {c.get('output_tokens', 0):,} output tokens"
498
+ cached = (c.get("cached") or {}).get("calls", 0)
499
+ reused = f"; {cached} more answers reused from the run directory" if cached else ""
500
+ failed = sum(bool(a.get("error")) for a in self.analyzed)
501
+ lines = [
502
+ f"# TabSchema discovery: `{self.target}`",
503
+ "",
504
+ f"* trajectories: {self.n_trajectories} ({self.n_successful} successful); decision points: "
505
+ f"{self.n_decision_points}; analyzed: {len(self.analyzed)}"
506
+ + (f" ({failed} failed, see analysis.json)" if failed else ""),
507
+ f"* LLM cost: {cost}; {c.get('calls', 0)} calls, {tokens}{reused}",
508
+ *(f"* {n}" for n in self.notes),
509
+ "",
510
+ "## Judged features",
511
+ "",
512
+ "| feature | relevance | generality | impact | decision | meta score |",
513
+ "|---|---|---|---|---|---|",
514
+ ]
515
+ for j in self.judged:
516
+ lines.append(
517
+ f"| {j['feature_name']} | {j.get('relevance', '')} | {j.get('generality', '')} | "
518
+ f"{j.get('impact', '')} | {j.get('final_decision', '')} | {j.get('meta_score', '')} |"
519
+ )
520
+ lines += _compiled_md(self.compiled, self.per_candidate, self.not_produced)
521
+ lines += _verification_md(self.verification) + _audit_md(self.audit) + _usefulness_md(self.usefulness)
522
+ if self.dropped:
523
+ lines += ["", "## Dropped", ""] + [f"* `{k}`: {v}" for k, v in self.dropped.items()]
524
+ return "\n".join(lines) + "\n"
525
+
526
+
527
+ def not_produced_counts(not_produced: dict[str, str]) -> dict[str, int]:
528
+ """Accepted features without a column, counted by reason: merged into another feature, declined, or not
529
+ produced."""
530
+ out = Counter(
531
+ "merged into another feature"
532
+ if r.startswith("merged into")
533
+ else "declined"
534
+ if r.startswith("declined")
535
+ else "not produced"
536
+ for r in not_produced.values()
537
+ )
538
+ return dict(sorted(out.items()))
539
+
540
+
541
+ def _compiled_md(
542
+ compiled: dict[str, str], per_candidate: list[str], not_produced: dict[str, str]
543
+ ) -> list[str]:
544
+ """The report's section on the compiled columns: per decision, per candidate, and the accepted features
545
+ that have none."""
546
+ per = set(per_candidate)
547
+ decision = [f"* `{k}` ({v})" for k, v in compiled.items() if k not in per]
548
+ candidate = [f"* `{k}` ({v})" for k, v in compiled.items() if k in per]
549
+ out = ["", "## Compiled extractor features", ""]
550
+ out += [
551
+ f"### Per-decision columns ({len(decision)}, `extract_features_for_classification`)",
552
+ "",
553
+ *decision,
554
+ ]
555
+ if candidate:
556
+ out += [
557
+ "",
558
+ f"### Per-candidate columns ({len(candidate)}, `extract_candidate_features`)",
559
+ "",
560
+ *candidate,
561
+ ]
562
+ if not_produced:
563
+ counts = "; ".join(f"{k}: {n}" for k, n in not_produced_counts(not_produced).items())
564
+ out += ["", f"### Accepted features without a column ({len(not_produced)}: {counts})", ""]
565
+ out += [f"* `{k}`: {v}" for k, v in not_produced.items()]
566
+ return out
567
+
568
+
569
+ def _verification_md(v: dict[str, Any]) -> list[str]:
570
+ """The report's section on the compile-time checks (``verify``)."""
571
+ if not v:
572
+ return []
573
+ out = ["", "## Verification of the extracted values (TabAgent's addition, not in the paper)", ""]
574
+ det = v.get("determinism", {})
575
+ unstable = det.get("unstable") or {}
576
+ out.append(
577
+ f"* determinism: every decision point ({det.get('prefixes', 0)}) run again in fresh interpreters, "
578
+ "in a shuffled order: "
579
+ + (
580
+ f"{len(unstable)} column(s) differ and are dropped ({', '.join(unstable)})"
581
+ if unstable
582
+ else "identical"
583
+ )
584
+ )
585
+ oc = v.get("outcome", {})
586
+ if "skipped" in oc:
587
+ out.append(f"* outcome invariance: skipped ({oc['skipped']})")
588
+ else:
589
+ changed = oc.get("changed") or {}
590
+ keys = ", ".join(oc.get("keys", [])[:6])
591
+ out.append(
592
+ f"* outcome invariance: adding the outcome fields ({keys}) to {v.get('sample', 0)} prefixes "
593
+ + (
594
+ f"changed {len(changed)} column(s), dropped ({', '.join(changed)})"
595
+ if changed
596
+ else "changed no feature"
597
+ )
598
+ )
599
+ fu = v.get("future", {})
600
+ if not fu.get("checked"):
601
+ reasons = "; ".join(fu.get("reasons") or {}) or "no decision point could be checked"
602
+ out.append(f"* future invariance: **skipped**, not passed ({reasons})")
603
+ elif fu.get("leaks"):
604
+ leak = fu["leaks"][0]
605
+ out.append(
606
+ f"* future invariance: **FAILED**: the adapter's prefix changed with the run's future on "
607
+ f"{len(fu['leaks'])} of {fu['checked']} decision points (first {leak['decision_point']}, at "
608
+ f"{leak['path']})"
609
+ )
610
+ else:
611
+ skipped = f"; {fu['skipped']} skipped" if fu.get("skipped") else ""
612
+ out.append(
613
+ f"* future invariance: {fu['checked']} prefixes rebuilt by the adapter from runs changed after "
614
+ f"the decision (and cut after it) are unchanged{skipped}"
615
+ )
616
+ rob = v.get("robustness", {})
617
+ cases = rob.get("cases") or []
618
+ raised = [c for c in cases if c.get("raised")]
619
+ line = f"* robustness (reported, not dropped): {len(cases)} edge prefixes; the extractor raised on "
620
+ line += str(len(raised))
621
+ if raised:
622
+ line += " (" + "; ".join(f"{c['case']}: {c['raised']}" for c in raised[:4]) + ")"
623
+ out.append(line)
624
+ gone = [
625
+ f"{c['case']}: {', '.join(c['missing'][:5])}"
626
+ for c in cases
627
+ if not c.get("raised") and c.get("missing")
628
+ ]
629
+ if gone:
630
+ out.append("* columns missing from the output: " + "; ".join(gone[:4]))
631
+ out.append(f"* {v.get('batches', 0)} sandbox runs on {v.get('prefixes_run', 0)} prefixes")
632
+ return out
633
+
634
+
635
+ def _audit_md(a: dict[str, Any]) -> list[str]:
636
+ """The report's section on the OutputAuditor (``audit``)."""
637
+ if not a:
638
+ return []
639
+ out = ["", "## Output audit (TabAgent's addition, not in the paper)", ""]
640
+ if a.get("skipped"):
641
+ return [*out, f"* skipped: {a['skipped']}"]
642
+ out.append(
643
+ f"* {a['answered']} of {a['samples']} sampled decision points judged by "
644
+ f"{a.get('model', 'the auditor')}; a feature is dropped when it was judged on at least "
645
+ f"{a['min_judged']} points, at least {a.get('min_incorrect', 2)} of them incorrect, and the "
646
+ f"one-sided 95% Wilson lower bound of its incorrect rate exceeds {a['max_incorrect']:.0%} (see "
647
+ "audit.json)"
648
+ )
649
+ if a.get("errors"):
650
+ out.append(f"* {len(a['errors'])} point(s) without a usable answer (first: {a['errors'][0][:200]})")
651
+ out += [
652
+ "",
653
+ "| feature | correct | incorrect | cannot tell | incorrect rate | kept |",
654
+ "|---|---|---|---|---|---|",
655
+ ]
656
+ for col, r in a.get("features", {}).items():
657
+ rate = "" if r["incorrect_rate"] is None else f"{r['incorrect_rate']:.0%}"
658
+ kept = "no" if col in a.get("dropped", {}) else "yes"
659
+ out.append(f"| {col} | {r['correct']} | {r['incorrect']} | {r['cannot_tell']} | {rate} | {kept} |")
660
+ return out
661
+
662
+
663
+ def _usefulness_md(u: dict[str, Any]) -> list[str]:
664
+ """The report's section on the usefulness estimate (``usefulness``)."""
665
+ if not u:
666
+ return []
667
+ out = ["", "## Usefulness of the features (TabAgent's addition, not in the paper)", ""]
668
+ if u.get("skipped"):
669
+ return [*out, f"* skipped: {u['skipped']}"]
670
+ m = u["metric"]
671
+ out.append(
672
+ f"* drop-feature importance (a feature's columns dropped together) on the onboarding rows: "
673
+ f"{u['decisions']} decision points "
674
+ f"({u.get('rows', '?')} rows) of {u['tasks']} tasks, {u['folds']} task-grouped folds, CatBoost with "
675
+ f"{u['iterations']} iterations, {u.get('seeds', 1)} seed(s); {m} {u.get('base', float('nan')):.3f} "
676
+ "with every feature"
677
+ )
678
+ pruning = u.get("pruned") is not None
679
+ out.append(
680
+ f"* delta: the head's {m} with the feature minus without it (95% bootstrap interval over tasks); "
681
+ "useful or harmful when the Holm-adjusted p-value (paired t-test over tasks) is below 0.05"
682
+ + (
683
+ " under every seed; harmful features are pruned"
684
+ if pruning
685
+ else "; a report only (prune_harmful=False)"
686
+ )
687
+ )
688
+ out += ["", f"| feature | delta {m} | 95% interval | Holm p | verdict |", "|---|---|---|---|---|"]
689
+ for col, r in sorted(u.get("features", {}).items(), key=lambda kv: -kv[1]["delta"]):
690
+ n = len(r.get("columns") or [col])
691
+ name = f"{col} ({n} columns)" if n > 1 else col
692
+ ci = f"[{r['ci'][0]:+.4f}, {r['ci'][1]:+.4f}]"
693
+ out.append(f"| {name} | {r['delta']:+.4f} | {ci} | {r.get('p_holm', '')} | {r['verdict']} |")
694
+ return out
695
+
696
+
697
+ def compile_record(compiled: CompiledExtractor) -> dict[str, Any]:
698
+ """The compile step as ``compile.json``: the attempts and why each failed one failed, the kept columns per
699
+ decision and per candidate, every kept column (type and the planned feature it implements), the accepted
700
+ features without a column with the reason (``not_produced``: merged into another feature, declined, not
701
+ produced), every column's quality statistics, every dropped column or feature with the reason, and the
702
+ notes."""
703
+ return {
704
+ "attempts": compiled.attempts,
705
+ "attempt_errors": compiled.attempt_errors,
706
+ "per_decision": compiled.context_columns,
707
+ "per_candidate": compiled.candidate_columns,
708
+ "not_produced": {
709
+ "count": len(compiled.not_produced),
710
+ "by_reason": not_produced_counts(compiled.not_produced),
711
+ "features": dict(compiled.not_produced),
712
+ },
713
+ "kept": {
714
+ col: {
715
+ "type": t,
716
+ "feature": (compiled.specs.get(col) or {}).get("feature_name"),
717
+ **({"per_candidate": True} if col in compiled.per_candidate else {}),
718
+ }
719
+ for col, t in compiled.features.items()
720
+ },
721
+ "dropped": dict(compiled.dropped),
722
+ "quality": compiled.quality,
723
+ "co_usage_context": compiled.co_usage_context,
724
+ "notes": list(compiled.notes),
725
+ }
726
+
727
+
728
+ @dataclass
729
+ class DiscoveryResult:
730
+ """The output of ``TabSchema.discover``: the feature card, the compiled extractor and the report."""
731
+
732
+ card: FeatureCard
733
+ extractor: CompiledExtractor
734
+ report: DiscoveryReport
735
+
736
+ def save(self, path: str | Path) -> Path:
737
+ """Write ``extract.py``, its golden tests (``extractor_tests.json``), ``card.yaml``, ``discovery.md``
738
+ and ``discovery.json`` to ``path``."""
739
+ out = Path(path)
740
+ out.mkdir(parents=True, exist_ok=True)
741
+ atomic_write(out / "extract.py", self.extractor.code)
742
+ if self.extractor.tests:
743
+ atomic_write(out / V.TESTS_FILE, json.dumps(self.extractor.tests, indent=1, ensure_ascii=False))
744
+ self.card.save(out / "card.yaml")
745
+ atomic_write(out / "discovery.md", self.report.to_markdown())
746
+ report = dataclasses.asdict(self.report)
747
+ atomic_write(out / "discovery.json", json.dumps(report, indent=1, default=str))
748
+ return out
749
+
750
+
751
+ class TabSchema:
752
+ """The FeatureOrchestrator: discovers a feature card for one decision component of an agent.
753
+
754
+ Parameters
755
+ ----------
756
+ llm: the model for every stage (default: ``auto_llm(config.model)``, from environment keys).
757
+ config: a ``TabSchemaConfig``.
758
+ adapter: reads the agent's logs into trajectories and decision points (default ``GenericJsonAdapter``).
759
+ store: the run directory (default: a new one under ``$TABAGENT_CACHE/runs``).
760
+ sandbox: runs LLM-written extraction code (default ``SubprocessSandbox``).
761
+ stage_llms: LLM backends by model name, for the models ``config.stage_models`` and
762
+ ``config.fallback_model`` name (default: built from the environment with ``auto_llm``).
763
+
764
+ With ``config.max_cost_usd`` set, the model needs a price in ``config.prices``; otherwise the budget
765
+ could not be enforced and the constructor raises ``ValueError``.
766
+ """
767
+
768
+ def __init__(
769
+ self,
770
+ llm: LLM | None = None,
771
+ config: TabSchemaConfig | None = None,
772
+ adapter=None,
773
+ store: RunStore | None = None,
774
+ sandbox: SubprocessSandbox | None = None,
775
+ stage_llms: dict[str, LLM] | None = None,
776
+ ):
777
+ self.config = config or TabSchemaConfig()
778
+ self.llm = llm or auto_llm(self.config.model, **_timeout(self.config))
779
+ self.adapter = adapter or GenericJsonAdapter()
780
+ self.store = store
781
+ self.sandbox = sandbox or SubprocessSandbox(timeout_s=self.config.timeout_s)
782
+ self.meter = CostMeter(prices=dict(self.config.prices), max_cost_usd=self.config.max_cost_usd)
783
+ unknown = sorted(set(self.config.stage_models) - set(STAGE_ROLES) - {"judges"})
784
+ if unknown:
785
+ raise ValueError(f"stage_models: unknown stages {unknown}; use {[*STAGE_ROLES, 'judges']}")
786
+ strategy = self.config.sample_strategy
787
+ if strategy not in STRATEGIES:
788
+ raise ValueError(f"sample_strategy must be one of {STRATEGIES}, not {strategy!r}")
789
+ self.llms = {self.llm.model: self.llm, **(stage_llms or {})}
790
+ for name in {*self.config.stage_models.values(), self.config.fallback_model} - {None}:
791
+ if name not in self.llms:
792
+ self.llms[name] = auto_llm(name, **_timeout(self.config))
793
+ for name in self.llms:
794
+ self.meter.require_price(name)
795
+
796
+ def llm_for(self, role: str) -> LLM:
797
+ """The LLM of a stage: its ``stage_models`` entry (``judges`` for all three judges), else ``llm``."""
798
+ models = self.config.stage_models
799
+ name = models.get(role) or (models.get("judges") if role.startswith("judge_") else None)
800
+ return self.llms[name] if name else self.llm
801
+
802
+ def models(self) -> dict[str, str]:
803
+ """The model of every stage, and the fallback (``"fallback"``) when one is set."""
804
+ out = {role: self.llm_for(role).model for role in STAGE_ROLES}
805
+ if self.config.fallback_model:
806
+ out["fallback"] = self.config.fallback_model
807
+ return out
808
+
809
+ def _stage(self, cls, *args):
810
+ st = cls(*args, self.llm, self.meter, self.store, self.config.json_retries)
811
+ st.llm = self.llm_for(st.role)
812
+ st.workers = self.config.workers
813
+ st.retry = dataclasses.replace(st.retry, attempts=self.config.llm_retries + 1)
814
+ fb = self.config.fallback_model
815
+ st.fallback = self.llms[fb] if fb else None
816
+ self._stages.append(st)
817
+ return st
818
+
819
+ @staticmethod
820
+ def _noted(stage, kind: str, candidates: frozenset[str]):
821
+ """``stage`` (the FeatureAggregator, a judge, the MetaJudge), told that the decision chooses among
822
+ candidates when it does, so that a feature of a candidate is judged as obtainable
823
+ (``Stage.noted``)."""
824
+ stage.per_candidate = kind != "single" and bool(candidates)
825
+ return stage
826
+
827
+ def _candidates(self, dp: DecisionPoint, kind: str) -> list:
828
+ """The adapter's candidates of ``dp``, [] for a single-class decision or an adapter that has none
829
+ (it raises ``NotImplementedError``). Any other error of the adapter is logged and raised: it must not
830
+ quietly turn the per-candidate features off."""
831
+ if kind == "single":
832
+ return []
833
+ try:
834
+ return list(self.adapter.candidates(dp) or ())
835
+ except NotImplementedError: # an adapter without candidates
836
+ return []
837
+ except Exception as e:
838
+ logger.error(
839
+ "TabSchema: the adapter's candidates of %s failed: %s: %s", dp.id, type(e).__name__, e
840
+ )
841
+ raise
842
+
843
+ def _progress(self, stage: str, what: str):
844
+ """A callback ``(done, total, failed)`` that records a stage's progress and logs it."""
845
+ log = getattr(self, "log", None)
846
+
847
+ def report(done: int, total: int, failed: int) -> None:
848
+ if log is not None:
849
+ logger.info("TabSchema: %s: %s", what, log.progress(stage, done, total, failed))
850
+
851
+ return report
852
+
853
+ def _events(self, since: int = 0) -> list[dict[str, Any]]:
854
+ """The events of the stages made since the ``since``-th one (unusable answers, split batches, ...)."""
855
+ return [e for st in self._stages[since:] for e in st.events]
856
+
857
+ # ------------------------------------------------------------------ discovery
858
+ def discover(
859
+ self,
860
+ trajectories: list[Trajectory] | str | Path,
861
+ target: str,
862
+ kind: str = "ranking",
863
+ name: str | None = None,
864
+ ) -> DiscoveryResult:
865
+ """Run the discovery loop for the decision component ``target``.
866
+
867
+ ``trajectories`` are ``Trajectory`` objects or a path the adapter reads; only successful ones are
868
+ used. ``kind`` is the decision pattern and ``name`` the card name. Every intermediate artifact is
869
+ written to the run directory, with ``run.json`` (the run's progress and anatomy,
870
+ ``tabagent runs show``); a run that stops leaves a partial ``report.md`` there, and rerunning with the
871
+ same directory resumes it.
872
+ """
873
+ cfg = self.config
874
+ trajs = (
875
+ self.adapter.load(trajectories) if isinstance(trajectories, (str, Path)) else list(trajectories)
876
+ )
877
+ good = [t for t in trajs if t.success]
878
+ if not good:
879
+ raise ValueError(
880
+ f"no successful trajectories among the {len(trajs)} given: TabSchema learns from successful "
881
+ "runs only (check the outcome field the adapter reads)"
882
+ )
883
+ dps = [dp for t in good for dp in self.adapter.decision_points(t, target)]
884
+ if not dps:
885
+ raise ValueError(f"no occurrences of {target!r} in the {len(good)} successful trajectories")
886
+ if len(dps) < 2:
887
+ raise ValueError(
888
+ f"TabSchema needs at least 2 decision points of {target!r}, found {len(dps)}: no feature can "
889
+ "vary across fewer"
890
+ )
891
+ store = self.store or RunStore()
892
+ self.store = store
893
+ self._stages = []
894
+ STOP.clear() # a backoff wait ends early only on an interruption of this run
895
+ report = DiscoveryReport(target, len(trajs), len(good), len(dps), run_dir=str(store.dir))
896
+ log = RunLog(store, self.meter, target)
897
+ self.log = log
898
+ log.set(
899
+ "data",
900
+ {
901
+ "trajectories": len(trajs),
902
+ "successful": len(good),
903
+ "decision_points": len(dps),
904
+ "tasks": len({str(dp.group or dp.trajectory_id) for dp in dps}),
905
+ },
906
+ )
907
+ store.write_json(
908
+ "config.json",
909
+ {
910
+ **cfg.to_dict(),
911
+ "target": target,
912
+ "kind": kind,
913
+ "model": self.llm.model,
914
+ "stage_models_resolved": self.models(),
915
+ "prompts_sha256": {p: prompts.sha256(p) for p in prompts.PROMPTS},
916
+ },
917
+ )
918
+ t0 = time.monotonic()
919
+ try:
920
+ with log.stage("adapter_check") as rec:
921
+ future = self.check_adapter(good, target, dps) # before any LLM call: a leak costs nothing
922
+ rec["produced"] = {
923
+ "checked": future.get("checked", 0),
924
+ "leaks": len(future.get("leaks") or []),
925
+ }
926
+ if future["notes"]:
927
+ report.notes += future["notes"]
928
+ compiled, decisions = self._run(
929
+ dps, target, store, report, candidate_ids(self.adapter, dps, kind=kind), good, kind, future
930
+ )
931
+ report.cost = self.meter.summary()
932
+ with log.stage("card") as rec:
933
+ card = self._card(compiled, decisions, kind, name or f"{target}_tabschema", len(dps))
934
+ card.save(store.path("card.yaml"))
935
+ rec["produced"] = {
936
+ "features": len(card.features),
937
+ "extractor columns": len(compiled.features),
938
+ }
939
+ except BaseException as e: # leave a partial report and run.json next to the paid answers, then fail
940
+ if isinstance(e, V.PrefixLeakError):
941
+ report.verification = e.record
942
+ report.notes.append(f"onboarding stopped before any LLM call: {e}")
943
+ forgotten = forget_cause(e) if isinstance(e, Exception) else 0
944
+ report.cost = self.meter.summary()
945
+ store.write_text("report.md", report.to_markdown())
946
+ status = stopped_status(e)
947
+ log.finish(status, e, {"drops_by_reason": drops_by_reason(report, cfg), "seconds": _secs(t0)})
948
+ if isinstance(e, (BudgetExceeded, ExtractionError, LLMFormatError, LLMUnavailable)):
949
+ if status == "stopped":
950
+ hint = "rerun with that run directory to resume (answers it holds cost nothing)"
951
+ else:
952
+ hint = "rerun with that run directory: the answers it holds cost nothing" + (
953
+ f", and the {forgotten} answer(s) that caused this failure are asked again"
954
+ if forgotten
955
+ else ""
956
+ )
957
+ raise type(e)(
958
+ f"{e}. The partial report, run.json and every paid answer are in {store.dir}: {hint}"
959
+ ) from e
960
+ if isinstance(e, Exception) and hasattr(e, "add_note"): # a provider's own error keeps its type
961
+ stage = next((k for k, r in log.data["stages"].items() if r.get("status") == status), "?")
962
+ e.add_note(
963
+ f"TabSchema stopped in stage {stage}; the partial report, run.json and every paid answer "
964
+ f"are in {store.dir} (rerun with it to resume)"
965
+ )
966
+ raise
967
+ logger.info(
968
+ "TabSchema: compiled an extractor with %d features (%d dropped); LLM: %s",
969
+ len(compiled.features),
970
+ len(compiled.dropped),
971
+ self.meter.describe(self.llm.model),
972
+ )
973
+ store.write_text("report.md", report.to_markdown())
974
+ log.finish(
975
+ "completed",
976
+ extra={
977
+ "drops_by_reason": drops_by_reason(report, cfg),
978
+ "seconds": _secs(t0),
979
+ "outputs": {"card": "card.yaml", "extractor": "extract.py", "report": "report.md"},
980
+ },
981
+ )
982
+ return DiscoveryResult(card, compiled, report)
983
+
984
+ def _analyze_point(
985
+ self,
986
+ dp: DecisionPoint,
987
+ target: str,
988
+ analyzer: FeatureAnalyzer,
989
+ extractor: CodeExtractor,
990
+ validator: CodeValidator,
991
+ known: set,
992
+ outcome_keys: tuple[str, ...],
993
+ kind: str = "ranking",
994
+ ) -> dict[str, Any]:
995
+ """One decision point's analysis: proposals, feasible code, the CodeValidator's verdicts. A failure
996
+ that is this point's own (an unusable answer, a content filter, code that never runs) loses the point
997
+ and is recorded; a spent budget, a provider that stays down, a provider's error (an invalid key, a
998
+ model that does not exist, a bad request) or an interruption stops the run."""
999
+ cfg = self.config
1000
+ rec: dict[str, Any] = {"decision_point": dp.id, "proposed": [], "kept": [], "error": ""}
1001
+ try:
1002
+ cands = [candidate_dict(c) for c in self._candidates(dp, kind)]
1003
+ shown = pick_examples(cands, cfg.sample_seed, dp.id) # never by the adapter's order or a label
1004
+ specs = analyzer(dp, target, cands) # with candidates, features of a candidate may be proposed
1005
+ rec["proposed"] = [s.feature_name for s in specs]
1006
+ executor = CodeExecutor(self.sandbox, known, outcome_keys)
1007
+ attempt, history = extract_with_retries(
1008
+ extractor,
1009
+ executor,
1010
+ specs,
1011
+ [dp.prefix],
1012
+ dp.prefix,
1013
+ target,
1014
+ cfg.max_code_retries,
1015
+ candidates=[cands] if cands else None,
1016
+ examples=shown,
1017
+ )
1018
+ rec["attempts"] = len(history)
1019
+ if not attempt.ok:
1020
+ rec["error"] = f"infeasible after {len(history)} attempts: {attempt.error[:300]}"
1021
+ rec["_forget"] = [(extractor, [p for h in history for p in h.prompts])]
1022
+ return {**rec, "specs": []}
1023
+ if attempt.candidate_error:
1024
+ rec["candidate_error"] = attempt.candidate_error[:300]
1025
+ more = {}
1026
+ if cands: # the per-candidate function's outputs, judged next to the prefix's
1027
+ outputs = candidate_samples(attempt.results, [cands], [dp.id], cfg.sample_seed)
1028
+ more = {"candidates": shown, "candidate_outputs": outputs}
1029
+ try:
1030
+ verdicts = validator(
1031
+ specs, attempt.code, dp.prefix, [attempt.results[0].features], target, **more
1032
+ )
1033
+ except LLMFormatError as e: # the deterministic checks passed; keep the proposals unjudged
1034
+ verdicts, rec["validator_error"] = {}, str(e)[:300]
1035
+ kept = [s for s in specs if verdicts.get(s.key, VALID) not in REJECTING]
1036
+ rec["kept"] = [s.feature_name for s in kept]
1037
+ rec["validator"] = verdicts
1038
+ odd = sorted({v for v in verdicts.values() if v not in VERDICTS})
1039
+ if odd:
1040
+ rec["validator_unrecognized"] = odd[:10]
1041
+ return {**rec, "specs": [s.to_dict() for s in kept]}
1042
+ except (BudgetExceeded, LLMUnavailable):
1043
+ raise
1044
+ except Exception as e: # one decision point's own failure (an unusable answer, a filter) is skipped
1045
+ if (
1046
+ status_code(e) is not None
1047
+ ): # a provider's error (an invalid key, a bad request): not the point's
1048
+ raise
1049
+ logger.warning("TabSchema: analyzing %s failed: %s: %s", dp.id, type(e).__name__, e)
1050
+ rec["error"] = f"{type(e).__name__}: {e}"[:500]
1051
+ rec["_forget"] = list(getattr(e, "forget", None) or [])
1052
+ return {**rec, "specs": []}
1053
+
1054
+ def _run(
1055
+ self,
1056
+ dps: list[DecisionPoint],
1057
+ target: str,
1058
+ store: RunStore,
1059
+ report: DiscoveryReport,
1060
+ candidates: frozenset[str] = frozenset(),
1061
+ trajectories: list[Trajectory] | None = None,
1062
+ kind: str = "ranking",
1063
+ future: dict[str, Any] | None = None,
1064
+ ) -> tuple[CompiledExtractor, list[dict]]:
1065
+ """The stages of :meth:`discover` after its checks: analysis, aggregation, judging, compilation and
1066
+ the release's audit and usefulness estimate. Fills ``report`` as it goes; returns the compiled
1067
+ extractor and the MetaJudge's decisions."""
1068
+ cfg = self.config
1069
+ log = getattr(self, "log", None) or RunLog(store, self.meter, target)
1070
+ if not hasattr(self, "_stages"):
1071
+ self._stages = []
1072
+ known = union_paths(dp.prefix for dp in dps)
1073
+ outcome_keys = tuple(getattr(self.adapter, "outcome_keys", ()))
1074
+ labels = getattr(self.adapter, "labels", None)
1075
+ with log.stage("sample") as rec:
1076
+ sample = sample_decision_points(dps, cfg.n_analyze, cfg.sample_seed, labels, cfg.sample_strategy)
1077
+ store.write_json("sample.json", [dp.id for dp in sample])
1078
+ covered = coverage(dps, sample, labels)
1079
+ store.write_json("sample_coverage.json", covered)
1080
+ rec["produced"] = {"points": len(sample), **{k: f"{a}/{b}" for k, (a, b) in covered.items()}}
1081
+ report.notes.append(
1082
+ f"analyzed sample ({cfg.sample_strategy}) covers "
1083
+ + ", ".join(f"{a}/{b} {kind}" for kind, (a, b) in covered.items())
1084
+ )
1085
+ logger.info(
1086
+ "TabSchema: %d decision points in %d successful trajectories; analyzing %d (run directory %s)",
1087
+ len(dps),
1088
+ report.n_successful,
1089
+ len(sample),
1090
+ store.dir,
1091
+ )
1092
+
1093
+ # --- analysis, one decision point per worker
1094
+ mark = len(self._stages)
1095
+ with log.stage("analyze") as rec:
1096
+ analyzer, extractor = self._stage(FeatureAnalyzer), self._stage(CodeExtractor)
1097
+ analyzer.max_chars, analyzer.seed = cfg.max_prompt_chars, cfg.sample_seed
1098
+ validator = self._stage(CodeValidator)
1099
+ pool = ThreadPoolExecutor(max_workers=cfg.workers)
1100
+ try:
1101
+ futures = [
1102
+ pool.submit(
1103
+ self._analyze_point,
1104
+ dp,
1105
+ target,
1106
+ analyzer,
1107
+ extractor,
1108
+ validator,
1109
+ known,
1110
+ outcome_keys,
1111
+ kind,
1112
+ )
1113
+ for dp in sample
1114
+ ]
1115
+ failed = 0
1116
+ for done, f in enumerate(as_completed(futures), 1):
1117
+ a = f.result()
1118
+ failed += bool(a["error"])
1119
+ logger.info(
1120
+ "TabSchema: analyzed decision points: %s",
1121
+ log.progress("analyze", done, len(sample), failed),
1122
+ )
1123
+ analyses = [f.result() for f in futures]
1124
+ except KeyboardInterrupt: # return at once; threads waiting in a backoff are woken
1125
+ shutdown_interrupted(pool)
1126
+ raise
1127
+ except BaseException: # a spent budget, say: drop the decision points not yet started
1128
+ pool.shutdown(wait=True, cancel_futures=True)
1129
+ raise
1130
+ pool.shutdown(wait=True)
1131
+ marks = [m for a in analyses for m in a.pop("_forget", None) or [] if a["error"]]
1132
+ unjudged = sum(bool(a.get("validator_error")) for a in analyses)
1133
+ if unjudged:
1134
+ report.notes.append(
1135
+ f"the CodeValidator's answer was unusable at {unjudged} analyzed decision point(s); "
1136
+ "their proposals went on unjudged by it (analysis.json)"
1137
+ )
1138
+ report.analyzed = [{k: v for k, v in a.items() if k != "specs"} for a in analyses]
1139
+ store.write_json("analysis.json", analyses)
1140
+ proposals = premerge([FeatureSpec.from_dict(x) for a in analyses for x in a["specs"]])
1141
+ rec["produced"] = {
1142
+ "points": len(analyses),
1143
+ "proposals": sum(len(a["proposed"]) for a in analyses),
1144
+ "feasible": sum(len(a["specs"]) for a in analyses),
1145
+ "distinct": len(proposals),
1146
+ }
1147
+ rec["failures"] = dict(Counter(_error_kind(a["error"]) for a in analyses if a["error"]))
1148
+ rec["events"] = _event_counts(self._events(mark))
1149
+ logger.info(
1150
+ "TabSchema: analyzed %d decision points (%d failed); %d feasible proposals, %d distinct",
1151
+ len(analyses),
1152
+ failed,
1153
+ sum(len(a["specs"]) for a in analyses),
1154
+ len(proposals),
1155
+ )
1156
+ if not proposals:
1157
+ errors = [a["error"] for a in analyses if a["error"]]
1158
+ first = f"; first error: {errors[0]}" if errors else ""
1159
+ err = RuntimeError(
1160
+ f"no feasible feature proposals from the {len(analyses)} analyzed decision points"
1161
+ f"{first}; see analysis.json in {store.dir}"
1162
+ )
1163
+ err.forget = marks # type: ignore[attr-defined]
1164
+ raise err
1165
+
1166
+ # --- aggregation
1167
+ mark = len(self._stages)
1168
+ with log.stage("aggregate") as rec:
1169
+ aggregator = self._noted(self._stage(FeatureAggregator), kind, candidates)
1170
+ filtered, rejected = aggregator(proposals)
1171
+ unaggregated = list(getattr(aggregator, "unaggregated", []))
1172
+ logger.info("TabSchema: aggregated %d proposals into %d features", len(proposals), len(filtered))
1173
+ report.aggregated = [f.feature_name for f in filtered]
1174
+ report.rejected_by_aggregator = [f.feature_name for f in rejected]
1175
+ events = self._events(mark)
1176
+ store.write_json(
1177
+ "aggregator.json",
1178
+ {
1179
+ "filtered": [f.to_dict() for f in filtered],
1180
+ "rejected": [f.to_dict() for f in rejected],
1181
+ **({"unaggregated": unaggregated} if unaggregated else {}),
1182
+ **({"events": events} if events else {}),
1183
+ },
1184
+ )
1185
+ rec["produced"] = {
1186
+ "proposals": len(proposals),
1187
+ "features": len(filtered),
1188
+ "rejected": len(rejected),
1189
+ }
1190
+ rec["events"] = _event_counts(events)
1191
+ if unaggregated:
1192
+ report.notes.append(
1193
+ "**the FeatureAggregator gave no usable answer for "
1194
+ f"{len(unaggregated)} proposal(s); they were judged as proposed, unaggregated** "
1195
+ "(aggregator.json)"
1196
+ )
1197
+ proposed = {p.key for p in proposals}
1198
+ renamed = [f.feature_name for f in filtered if f.key not in proposed]
1199
+ if renamed:
1200
+ report.notes.append(
1201
+ "the FeatureAggregator kept features under names no analyzer proposed (judged without "
1202
+ f"the analyzer's view): {', '.join(renamed)}"
1203
+ )
1204
+ if not filtered:
1205
+ raise ExtractionError(
1206
+ f"the FeatureAggregator kept none of the {len(proposals)} proposals; see aggregator.json"
1207
+ )
1208
+
1209
+ # --- the three judges, then the MetaJudge
1210
+ mark = len(self._stages)
1211
+ with log.stage("judge") as rec:
1212
+ verdicts = run_judges(
1213
+ filtered,
1214
+ lambda d: self._noted(self._stage(Judge, d), kind, candidates),
1215
+ workers=max(3, cfg.workers),
1216
+ stages=[],
1217
+ )
1218
+ table = judge_table(verdicts, proposals)
1219
+ unscored = {d: len(filtered) - len(v) for d, v in verdicts.items() if len(v) < len(filtered)}
1220
+ rec["produced"] = {
1221
+ "features": len(filtered),
1222
+ **{f"{d} scores": len(v) for d, v in verdicts.items()},
1223
+ }
1224
+ rec["events"] = _event_counts(self._events(mark))
1225
+ if unscored:
1226
+ rec["failures"] = {f"no {d} score": n for d, n in unscored.items()}
1227
+ report.notes += [
1228
+ f"**the {d} judge gave no usable score for {n} of {len(filtered)} features; the "
1229
+ "MetaJudge decided on them without it** (judges.json)"
1230
+ for d, n in unscored.items()
1231
+ ]
1232
+ judge_events = self._events(mark)
1233
+ mark = len(self._stages)
1234
+ with log.stage("meta_judge") as rec:
1235
+ decisions = self._noted(self._stage(MetaJudge), kind, candidates)(verdicts, table)
1236
+ events = [*judge_events, *self._events(mark)]
1237
+ store.write_json(
1238
+ "judges.json",
1239
+ {
1240
+ "verdicts": verdicts,
1241
+ "table": table,
1242
+ "meta": decisions,
1243
+ **({"events": events} if events else {}),
1244
+ },
1245
+ )
1246
+ accept = {d for d in cfg.accept if d in DECISIONS}
1247
+ by_key = {normalize_name(d["feature_name"]): d for d in decisions}
1248
+ accepted = [f for f in filtered if f.key in by_key and by_key[f.key]["final_decision"] in accept]
1249
+ for e in self._events(mark): # decisions on names the MetaJudge was not given: ignored
1250
+ for name in e.get("features", []) if e.get("event") == "ignored" else []:
1251
+ report.dropped[str(name)] = (
1252
+ "the MetaJudge decided on a name it was not given (renamed or invented); ignored"
1253
+ )
1254
+ for f in filtered:
1255
+ if f.key not in by_key:
1256
+ report.dropped[f.feature_name] = "the MetaJudge gave no decision on it"
1257
+ logger.info(
1258
+ "TabSchema: judged %d features; the MetaJudge accepted %d", len(filtered), len(accepted)
1259
+ )
1260
+ scores = {normalize_name(row["feature_name"]): row for row in table}
1261
+ for d in decisions:
1262
+ row = scores.get(normalize_name(d["feature_name"]), {})
1263
+ report.judged.append(
1264
+ {
1265
+ "feature_name": d["feature_name"],
1266
+ "final_decision": d["final_decision"],
1267
+ "meta_score": d.get("meta_score"),
1268
+ **{k: row.get(f"judge_{k}_score") for k in ("relevance", "generality", "impact")},
1269
+ }
1270
+ )
1271
+ counts = {
1272
+ k: sum(by_key[f.key]["final_decision"] == k for f in filtered if f.key in by_key)
1273
+ for k in DECISIONS
1274
+ }
1275
+ rec["produced"] = {**counts, "compiled": len(accepted)}
1276
+ rec["events"] = _event_counts(self._events(mark))
1277
+ if not accepted:
1278
+ raise ExtractionError(
1279
+ f"the MetaJudge accepted none of the {len(filtered)} aggregated features ({counts}); see "
1280
+ f"judges.json. TabSchemaConfig.accept (now {cfg.accept}) sets which of accept, "
1281
+ "conditional and reject are compiled"
1282
+ )
1283
+
1284
+ # --- one extractor for the accepted features
1285
+ mark = len(self._stages)
1286
+ with log.stage("compile") as rec:
1287
+ if future is None and trajectories: # a caller that skipped discover's check
1288
+ future = self.check_adapter(trajectories, target, dps)
1289
+ extractor, validator = self._stage(CodeExtractor), self._stage(CodeValidator)
1290
+ logger.info(
1291
+ "TabSchema: compiling one extractor for %d features on %d decision points",
1292
+ len(accepted),
1293
+ len(dps),
1294
+ )
1295
+ try:
1296
+ compiled = compile_extractor(
1297
+ accepted,
1298
+ dps,
1299
+ extractor,
1300
+ validator,
1301
+ target,
1302
+ sandbox=self.sandbox,
1303
+ known_paths=known,
1304
+ outcome_keys=outcome_keys,
1305
+ max_retries=cfg.max_code_retries,
1306
+ min_success=cfg.compile_min_success,
1307
+ max_null_rate=cfg.max_null_rate,
1308
+ max_columns_per_feature=cfg.max_columns_per_feature,
1309
+ max_columns=cfg.max_extractor_columns,
1310
+ repair=cfg.compile_repair,
1311
+ reserved=RESERVED,
1312
+ candidate_ids=candidates,
1313
+ context_text=cfg.context_text,
1314
+ verify=cfg.verify,
1315
+ verify_samples=cfg.verify_samples,
1316
+ future=future,
1317
+ goldens=cfg.goldens,
1318
+ seed=cfg.sample_seed,
1319
+ steps_key=getattr(self.adapter, "steps_key", "steps"),
1320
+ label_of=labels,
1321
+ adapter_name=type(self.adapter).__name__,
1322
+ drop_redundant_text=cfg.drop_redundant_text,
1323
+ candidates_of=(lambda dp: self._candidates(dp, kind)) if candidates else None,
1324
+ last_step_text=cfg.last_step_text,
1325
+ positional_guard=cfg.positional_guard,
1326
+ )
1327
+ except V.PrefixLeakError as e: # the report says so, next to every paid answer
1328
+ report.verification = e.record
1329
+ report.notes.append(f"onboarding stopped: {e}")
1330
+ raise
1331
+ store.write_text("extract.py", compiled.code)
1332
+ report.verification = {k: v for k, v in compiled.verification.items() if k != "seconds"}
1333
+ rec["produced"] = {
1334
+ "columns": len(compiled.features),
1335
+ "code attempts": compiled.attempts,
1336
+ "decision points without features": sum(o is None for o in compiled.outputs),
1337
+ }
1338
+ rec["dropped"] = dict(Counter(drop_reason(r) for r in compiled.dropped.values()))
1339
+ rec["events"] = _event_counts(self._events(mark))
1340
+ if cfg.audit:
1341
+ with log.stage("audit") as rec:
1342
+ mark = len(self._stages)
1343
+ analyzed = frozenset(dp.id for dp in sample)
1344
+ self._audit(compiled, dps, target, store, report, analyzed, labels, kind)
1345
+ rec["events"] = _event_counts(self._events(mark))
1346
+ a = report.audit
1347
+ rec["produced"] = {"points": a.get("samples", 0), "answered": a.get("answered", 0)}
1348
+ rec["dropped"] = {"judged incorrect": len(a.get("dropped") or {})} if a.get("dropped") else {}
1349
+ if a.get("skipped"):
1350
+ rec["note"] = a["skipped"]
1351
+ if a.get("errors"):
1352
+ rec["failures"] = dict(Counter(_error_kind(e) for e in a["errors"]))
1353
+ if cfg.usefulness:
1354
+ with log.stage("usefulness") as rec:
1355
+ self._usefulness(compiled, dps, decisions, kind, store, report, outcome_keys)
1356
+ u = report.usefulness
1357
+ rec["produced"] = dict(Counter(r.get("verdict") for r in (u.get("features") or {}).values()))
1358
+ if u.get("skipped"):
1359
+ rec["note"] = u["skipped"]
1360
+ compiled.tests = V.restrict_tests(compiled.tests, compiled.features)
1361
+ if compiled.tests:
1362
+ store.write_json(V.TESTS_FILE, compiled.tests)
1363
+ store.write_json("compile.json", compile_record(compiled))
1364
+ report.compiled = dict(compiled.features)
1365
+ report.per_candidate = compiled.candidate_columns
1366
+ report.not_produced = dict(compiled.not_produced)
1367
+ report.dropped.update(compiled.dropped)
1368
+ report.notes += compiled.notes
1369
+ return compiled, decisions
1370
+
1371
+ # ------------------------------------------------------------------ the release's checks
1372
+ def check_adapter(
1373
+ self, trajectories: list[Trajectory], target: str, dps: list[DecisionPoint] | None = None
1374
+ ) -> dict[str, Any]:
1375
+ """The adapter's future-invariance check (``verify.check_adapter``) on the successful
1376
+ ``trajectories``, before any LLM call: raises ``PrefixLeakError`` when the adapter's prefixes change
1377
+ with the run's future. With ``verify`` or ``verify_adapter`` off it is skipped with a warning. The
1378
+ result is kept, so discovery after a preflight does not run it twice."""
1379
+ cfg = self.config
1380
+ good = [t for t in trajectories if t.success]
1381
+ key = (target, tuple(t.id for t in good))
1382
+ if getattr(self, "_adapter_check", (None, None))[0] == key:
1383
+ return self._adapter_check[1]
1384
+ if not (cfg.verify and cfg.verify_adapter):
1385
+ which = "verify=False" if not cfg.verify else "verify_adapter=False"
1386
+ msg = (
1387
+ f"the adapter's leak check (future invariance) was skipped ({which}): post-decision data in "
1388
+ "prefixes would go unnoticed"
1389
+ )
1390
+ logger.warning("TabSchema: %s", msg)
1391
+ rec = {**V.skipped_future(f"turned off ({which})"), "notes": [msg]}
1392
+ else:
1393
+ if dps is None:
1394
+ dps = [dp for t in good for dp in self.adapter.decision_points(t, target)]
1395
+ rec = V.check_adapter(
1396
+ self.adapter,
1397
+ good,
1398
+ dps,
1399
+ target,
1400
+ samples=cfg.verify_samples,
1401
+ seed=cfg.sample_seed,
1402
+ label_of=getattr(self.adapter, "labels", None),
1403
+ )
1404
+ self._adapter_check = (key, rec)
1405
+ return rec
1406
+
1407
+ def _audit(
1408
+ self,
1409
+ compiled: CompiledExtractor,
1410
+ dps: list[DecisionPoint],
1411
+ target: str,
1412
+ store: RunStore,
1413
+ report: DiscoveryReport,
1414
+ analyzed: frozenset[str],
1415
+ labels: Any = None,
1416
+ kind: str = "ranking",
1417
+ ) -> None:
1418
+ """Run the OutputAuditor on a sample of decision points and drop the features it judges incorrect too
1419
+ often; writes ``audit.json``. The audit only ever removes features: when it would remove them all, it
1420
+ removes none and says so."""
1421
+ cfg = self.config
1422
+ auditor = self._stage(OutputAuditor)
1423
+ columns = {c: compiled.specs[c] for c in compiled.llm_columns if c not in compiled.per_candidate}
1424
+ per = {c: compiled.specs[c] for c in compiled.candidate_columns}
1425
+ logger.info(
1426
+ "TabSchema: auditing the values of %d features on %d decision points",
1427
+ len(columns) + len(per),
1428
+ cfg.audit_samples,
1429
+ )
1430
+ rec = run_audit(
1431
+ auditor,
1432
+ columns,
1433
+ dps,
1434
+ compiled.outputs,
1435
+ target,
1436
+ samples=cfg.audit_samples,
1437
+ max_incorrect=cfg.audit_max_incorrect,
1438
+ seed=cfg.sample_seed,
1439
+ exclude=analyzed,
1440
+ workers=cfg.workers,
1441
+ label_of=labels,
1442
+ progress=self._progress("audit", "audited decision points"),
1443
+ **(
1444
+ {
1445
+ "candidate_columns": per,
1446
+ "candidate_outputs": compiled.candidate_outputs,
1447
+ "candidates_of": lambda dp: [candidate_dict(c) for c in self._candidates(dp, kind)],
1448
+ }
1449
+ if per
1450
+ else {}
1451
+ ),
1452
+ )
1453
+ rec["model"] = auditor.llm.model
1454
+ if rec["dropped"] and len(rec["dropped"]) >= len(compiled.llm_columns):
1455
+ rec["not_applied"] = rec.pop("dropped")
1456
+ rec["dropped"] = {}
1457
+ compiled.notes.append(
1458
+ "**the OutputAuditor judged every LLM-written feature incorrect too often; none was dropped "
1459
+ "(the audit never empties a card): review the features and audit.json before deploying**"
1460
+ )
1461
+ for col, reason in rec["dropped"].items():
1462
+ compiled.drop(col, reason)
1463
+ if rec["skipped"]:
1464
+ compiled.notes.append(f"output audit skipped: {rec['skipped']}")
1465
+ if rec.get("analyzed_points"):
1466
+ compiled.notes.append(
1467
+ f"output audit: {rec['analyzed_points']} of the audited decision points were also analyzed "
1468
+ "(fewer unanalyzed points than audit_samples)"
1469
+ )
1470
+ store.write_json("audit.json", rec)
1471
+ report.audit = {k: v for k, v in rec.items() if k != "points"}
1472
+
1473
+ def _usefulness(
1474
+ self,
1475
+ compiled: CompiledExtractor,
1476
+ dps: list[DecisionPoint],
1477
+ decisions: list[dict],
1478
+ kind: str,
1479
+ store: RunStore,
1480
+ report: DiscoveryReport,
1481
+ outcome_keys: tuple[str, ...],
1482
+ ) -> None:
1483
+ """Estimate each extractor feature's usefulness on the onboarding rows (and, with ``prune_harmful``,
1484
+ drop the clearly harmful LLM-written ones); writes ``usefulness.json``."""
1485
+ cfg = self.config
1486
+ idx = {dp.id: i for i, dp in enumerate(dps)}
1487
+ sample = (
1488
+ dps
1489
+ if len(dps) <= cfg.usefulness_max_decisions
1490
+ else sample_decision_points(dps, cfg.usefulness_max_decisions, cfg.sample_seed, strategy="tasks")
1491
+ )
1492
+ appended = [c for c in compiled.features if compiled.specs.get(c, {}).get("added_by")]
1493
+
1494
+ def features_of(points: list[DecisionPoint]) -> list[dict]:
1495
+ feats = [dict(compiled.outputs[idx[dp.id]] or {}) for dp in points]
1496
+ for col in appended: # the appended texts are library code: computed here, as in the sandbox
1497
+ which = compiled.specs[col].get("appended", CONTEXT_TEXT_COLUMN)
1498
+ steps = getattr(self.adapter, "steps_key", "steps")
1499
+ texts = appended_texts([dp.prefix for dp in points], col, which, outcome_keys, steps)
1500
+ for f, text in zip(feats, texts, strict=True):
1501
+ f[col] = text
1502
+ return feats
1503
+
1504
+ def candidates_of(points: list[DecisionPoint]) -> list | None:
1505
+ if not compiled.candidate_columns:
1506
+ return None
1507
+ out = []
1508
+ for dp in points:
1509
+ values = compiled.candidate_outputs[idx[dp.id]]
1510
+ ids = [c.id for c in self._candidates(dp, kind)]
1511
+ out.append(None if values is None else dict(zip(ids, values, strict=True)))
1512
+ return out
1513
+
1514
+ def card_for(dropped: frozenset[str]) -> FeatureCard:
1515
+ return self._card(
1516
+ compiled.without(dropped), decisions, kind, "usefulness", len(dps), embeddings=False
1517
+ )
1518
+
1519
+ try:
1520
+ table = rows_to_table(
1521
+ card_for(frozenset()), sample, features_of(sample), self.adapter, candidates_of(sample)
1522
+ )
1523
+ if len(table) > cfg.usefulness_max_rows and len(sample) > MIN_DECISIONS: # bound the runtime
1524
+ n = max(MIN_DECISIONS, int(len(sample) * cfg.usefulness_max_rows / len(table)))
1525
+ sample = sample_decision_points(sample, n, cfg.sample_seed, strategy="tasks")
1526
+ table = rows_to_table(
1527
+ card_for(frozenset()), sample, features_of(sample), self.adapter, candidates_of(sample)
1528
+ )
1529
+ logger.info(
1530
+ "TabSchema: estimating the usefulness of %d features on %d decision points (%d rows)",
1531
+ len(compiled.features),
1532
+ len(sample),
1533
+ len(table),
1534
+ )
1535
+ rec = feature_usefulness(
1536
+ table,
1537
+ card_for,
1538
+ feature_groups(compiled),
1539
+ seed=cfg.sample_seed,
1540
+ seeds=2 if cfg.prune_harmful else 1,
1541
+ )
1542
+ except Exception as e: # a report only: no candidates or labels, one label value, a fit that fails
1543
+ logger.warning("TabSchema: the usefulness estimate was skipped: %s: %s", type(e).__name__, e)
1544
+ rec = {"skipped": f"{type(e).__name__}: {e}"[:300]}
1545
+ if cfg.prune_harmful and not rec.get("skipped"):
1546
+ harmful_columns = {
1547
+ c
1548
+ for name, r in rec["features"].items()
1549
+ if r.get("verdict") == "harmful" and name != ALL_LLM
1550
+ for c in r.get("columns", [name])
1551
+ }
1552
+ harmful = [c for c in compiled.llm_columns if c in harmful_columns]
1553
+ by_column = {
1554
+ c: r
1555
+ for name, r in rec["features"].items()
1556
+ if name != ALL_LLM
1557
+ for c in r.get("columns", [name])
1558
+ }
1559
+ if harmful and len(harmful) >= len(compiled.llm_columns):
1560
+ compiled.notes.append(
1561
+ "prune_harmful: every LLM-written feature looked harmful; none was pruned"
1562
+ )
1563
+ harmful = []
1564
+ for col in harmful:
1565
+ r = by_column[col]
1566
+ compiled.drop(
1567
+ col,
1568
+ f"harmful (prune_harmful): removing it raised {rec['metric']} by {-r['delta']:.4f} "
1569
+ f"(Holm-adjusted p {r['p_holm']}, under {rec['seeds']} seeds) in grouped CV on the "
1570
+ "onboarding rows",
1571
+ )
1572
+ rec["pruned"] = harmful
1573
+ if harmful:
1574
+ compiled.notes.append(
1575
+ f"prune_harmful dropped {len(harmful)} feature(s) chosen on the onboarding rows, so "
1576
+ "cross-validation on those rows (the onboarding report) is optimistic; "
1577
+ "tabagent.cross_validate_discovery prunes inside each training fold"
1578
+ )
1579
+ store.write_json("usefulness.json", rec)
1580
+ report.usefulness = {k: v for k, v in rec.items() if k != "seconds"}
1581
+
1582
+ # ------------------------------------------------------------------ card and table
1583
+ def _card(
1584
+ self,
1585
+ compiled: CompiledExtractor,
1586
+ decisions: list[dict],
1587
+ kind: str,
1588
+ name: str,
1589
+ n_dps: int,
1590
+ embeddings: bool | None = None,
1591
+ ) -> FeatureCard:
1592
+ cfg = self.config
1593
+ meta = {normalize_name(d["feature_name"]): d for d in decisions}
1594
+ feats: list[Feature] = []
1595
+ per_candidate: list[Feature] = []
1596
+ for col, typ in compiled.features.items():
1597
+ plan = compiled.specs.get(col, {})
1598
+ prov = {"plan": plan, "meta_judge": meta.get(normalize_name(plan.get("feature_name", "")), {})}
1599
+ if col in compiled.per_candidate: # computed per (decision, candidate) row: a schema feature
1600
+ per_candidate.append(
1601
+ Feature(
1602
+ col,
1603
+ "schema",
1604
+ typ,
1605
+ "extractor_candidate",
1606
+ description=plan.get("description", ""),
1607
+ provenance=prov,
1608
+ )
1609
+ )
1610
+ continue
1611
+ feats.append(
1612
+ Feature(
1613
+ col, "state", typ, "extractor", description=plan.get("description", ""), provenance=prov
1614
+ )
1615
+ )
1616
+ if kind == "single":
1617
+ spec = DecisionSpec("single", GROUP, "label", decision=(DECISION,))
1618
+ else:
1619
+ spec = DecisionSpec(kind, GROUP, LABEL, CANDIDATE, decision=(DECISION,))
1620
+ feats += [
1621
+ Feature("candidate_name", "schema", "cat"),
1622
+ Feature("candidate_description", "schema", "text"),
1623
+ Feature("candidate_args", "schema", "text"),
1624
+ *per_candidate,
1625
+ ]
1626
+ context = [f.name for f in feats if f.source == "extractor" and f.type == "text"]
1627
+ cand_text = ["candidate_name", "candidate_description"]
1628
+ dep = set(cfg.dependency_features)
1629
+ if context and "context_candidate_lex" in dep:
1630
+ feats.append(
1631
+ Feature(
1632
+ "context_candidate_lex", "schema", "num", "jaccard", {"a": context, "b": cand_text}
1633
+ )
1634
+ )
1635
+ use_emb = cfg.pair_embeddings if embeddings is None else embeddings
1636
+ if use_emb is None:
1637
+ from tabagent.auto import embeddings_available
1638
+
1639
+ use_emb = embeddings_available()
1640
+ if context and use_emb and "context_candidate_sim" in dep:
1641
+ feats.append(
1642
+ Feature(
1643
+ "context_candidate_sim",
1644
+ "dependency",
1645
+ "num",
1646
+ "fitted:embedding_cosine",
1647
+ {"query": context, "passage": cand_text, "encoder": "e5-small-v2"},
1648
+ )
1649
+ )
1650
+ if "candidate_prior" in dep:
1651
+ feats.append(
1652
+ Feature(
1653
+ "candidate_prior", "dependency", "num", "fitted:candidate_prior", {"key": CANDIDATE}
1654
+ )
1655
+ )
1656
+ if compiled.co_usage_context and "co_usage" in dep:
1657
+ feats.append(
1658
+ Feature(
1659
+ "co_usage",
1660
+ "dependency",
1661
+ "num",
1662
+ "fitted:co_usage",
1663
+ {"context": compiled.co_usage_context, "key": CANDIDATE},
1664
+ )
1665
+ )
1666
+ card = FeatureCard(
1667
+ name,
1668
+ spec,
1669
+ feats,
1670
+ synth_text=[f.name for f in feats if f.source == "extractor" and f.type == "text"],
1671
+ synth_k=4,
1672
+ provenance={
1673
+ "kind": "tabschema-llm",
1674
+ "model": self.llm.model,
1675
+ **({"models": self.models()} if len(set(self.models().values())) > 1 else {}),
1676
+ "decision_points": n_dps,
1677
+ "prompts_sha256": {p: prompts.sha256(p) for p in prompts.PROMPTS},
1678
+ # what the card cost, including answers reused from the run directory at their first cost
1679
+ "cost_usd": round(self.meter.total_usd + (self.meter.cached_usd or 0.0), 4),
1680
+ },
1681
+ extractor={
1682
+ "path": "extract.py",
1683
+ "sha256": compiled.sha256,
1684
+ "entrypoint": "extract_features_for_classification",
1685
+ **({"candidate_entrypoint": CANDIDATE_FUNCTION} if per_candidate else {}),
1686
+ },
1687
+ )
1688
+ return card # the relative features are the recipe search's challenger (auto.select), never here
1689
+
1690
+ def build_table(
1691
+ self,
1692
+ result: DiscoveryResult,
1693
+ trajectories: list[Trajectory],
1694
+ target: str,
1695
+ trust_extractor: bool = False,
1696
+ ) -> DecisionTable:
1697
+ """The decision table: one row per decision point x candidate, with the extractor's features computed
1698
+ on each prefix and labels from the adapter (for tool shortlisting, the tools the successful run
1699
+ used; Sec. 4.1). The extractor runs in the sandbox unless ``trust_extractor``."""
1700
+ dps = [dp for t in trajectories if t.success for dp in self.adapter.decision_points(t, target)]
1701
+ runner = ExtractorRunner(result.extractor.code, trust_extractor, self.sandbox)
1702
+ feats, per = extract_rows(runner, result.card, dps, self.adapter)
1703
+ return rows_to_table(result.card, dps, feats, self.adapter, per)
1704
+
1705
+
1706
+ def candidate_ids(
1707
+ adapter, dps: list[DecisionPoint], probe: int = 200, kind: str = "single"
1708
+ ) -> frozenset[str]:
1709
+ """The candidate ids of the first ``probe`` decision points (empty when the adapter gives none), which
1710
+ tell the compile step which extractor column lists earlier candidates (the co-usage context). For a
1711
+ ranking or multi-label decision only an adapter without candidates (``NotImplementedError``) gives none;
1712
+ any other error of the adapter is logged and raised, as it must not quietly turn the per-candidate
1713
+ features off."""
1714
+ try:
1715
+ return frozenset(c.id for dp in dps[:probe] for c in adapter.candidates(dp))
1716
+ except NotImplementedError: # an adapter without candidates
1717
+ return frozenset()
1718
+ except Exception as e:
1719
+ if kind == "single": # a single-class decision need not have candidates
1720
+ return frozenset()
1721
+ logger.error("TabSchema: the adapter's candidates failed: %s: %s", type(e).__name__, e)
1722
+ raise
1723
+
1724
+
1725
+ def extract_rows(
1726
+ runner: ExtractorRunner, card: FeatureCard, dps: list[DecisionPoint], adapter
1727
+ ) -> tuple[list[dict | None], list[dict[str, dict | None]] | None]:
1728
+ """The extractor's features for ``dps``, as :func:`rows_to_table` takes them: per decision point, and,
1729
+ when the card has per-candidate features, per candidate of each, by candidate id (one sandbox call per
1730
+ decision)."""
1731
+ prefixes = [dp.prefix for dp in dps]
1732
+ if not any(f.source == "extractor_candidate" for f in card.features) or not runner.has_candidate_function:
1733
+ return runner.run(prefixes), None
1734
+ candidates = candidate_lists([adapter.candidates(dp) for dp in dps])
1735
+ feats, per = runner.run_items(prefixes, candidates)
1736
+ by_id = [
1737
+ None if values is None else {c["id"]: v for c, v in zip(cands, values, strict=True)}
1738
+ for cands, values in zip(candidates, per, strict=True)
1739
+ ]
1740
+ return feats, by_id
1741
+
1742
+
1743
+ def rows_to_table(
1744
+ card: FeatureCard,
1745
+ dps: list[DecisionPoint],
1746
+ feats: list[dict | None],
1747
+ adapter,
1748
+ candidate_feats: list[dict[str, dict | None] | None] | None = None,
1749
+ ) -> DecisionTable:
1750
+ """Assemble the decision table from decision points, their extracted features and the adapter's labels.
1751
+ ``candidate_feats`` holds the per-candidate features of each decision point by candidate id (None where
1752
+ there are none); they fill the card's per-candidate columns of each candidate's row."""
1753
+ cols = [f.name for f in card.features if f.source == "extractor"]
1754
+ per_cols = [f.name for f in card.features if f.source == "extractor_candidate"]
1755
+ rows = []
1756
+ for i, (dp, f) in enumerate(zip(dps, feats, strict=True)):
1757
+ base = {
1758
+ GROUP: str(dp.group or dp.trajectory_id),
1759
+ DECISION: dp.id,
1760
+ **{c: (f or {}).get(c) for c in cols},
1761
+ }
1762
+ if card.spec.kind == "single":
1763
+ labels = adapter.labels(dp) or {None}
1764
+ if len(labels) != 1:
1765
+ raise ValueError(
1766
+ f"kind='single' needs one label per decision, but {dp.id} has {len(labels)} "
1767
+ f"({sorted(map(str, labels))[:5]}); use kind='ranking' or 'multilabel'"
1768
+ )
1769
+ (label,) = labels
1770
+ rows.append({**base, "label": label})
1771
+ continue
1772
+ chosen = adapter.labels(dp)
1773
+ cands = adapter.candidates(dp)
1774
+ per = (candidate_feats[i] if candidate_feats is not None else None) or {}
1775
+ for c in cands:
1776
+ row = {
1777
+ **base,
1778
+ CANDIDATE: c.id,
1779
+ "candidate_name": c.name or c.id,
1780
+ "candidate_description": c.description,
1781
+ "candidate_args": c.arg_hints,
1782
+ }
1783
+ if per_cols:
1784
+ row.update({col: (per.get(c.id) or {}).get(col) for col in per_cols})
1785
+ row[LABEL] = int(c.id in chosen)
1786
+ rows.append(row)
1787
+ return DecisionTable(pd.DataFrame(rows), card.spec)
1788
+
1789
+
1790
+ def _secs(t0: float) -> float:
1791
+ return round(time.monotonic() - t0, 2)
1792
+
1793
+
1794
+ def _error_kind(error: str) -> str:
1795
+ """The kind of a recorded error (its exception name, or what failed)."""
1796
+ text = str(error)
1797
+ if text.startswith("infeasible after"):
1798
+ return "code never ran"
1799
+ head = text.split(":", 1)[0].strip()
1800
+ return head if head and " " not in head else text[:60]
1801
+
1802
+
1803
+ def _event_counts(events: list[dict[str, Any]]) -> dict[str, int]:
1804
+ return dict(Counter(f"{e.get('stage')}: {e.get('event')}" for e in events))
1805
+
1806
+
1807
+ def drops_by_reason(report: DiscoveryReport, cfg: TabSchemaConfig) -> dict[str, int]:
1808
+ """Every dropped decision point, proposal, feature and column of a run, counted by reason."""
1809
+ out: Counter = Counter()
1810
+ for a in report.analyzed:
1811
+ if a.get("error"):
1812
+ out[f"analysis: decision point failed ({_error_kind(a['error'])})"] += 1
1813
+ else:
1814
+ out["analysis: proposal flagged by the CodeValidator"] += len(a.get("proposed") or []) - len(
1815
+ a.get("kept") or []
1816
+ )
1817
+ out["FeatureAggregator: rejected"] += len(report.rejected_by_aggregator)
1818
+ for j in report.judged:
1819
+ if j.get("final_decision") not in cfg.accept:
1820
+ out[f"MetaJudge: {j.get('final_decision')}"] += 1
1821
+ for reason in report.dropped.values():
1822
+ out[drop_reason(reason)] += 1
1823
+ return {k: v for k, v in out.most_common() if v}