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.
- tabagent/__init__.py +57 -0
- tabagent/adapters/__init__.py +68 -0
- tabagent/adapters/common.py +138 -0
- tabagent/adapters/cuga.py +623 -0
- tabagent/adapters/detect.py +182 -0
- tabagent/adapters/live.py +111 -0
- tabagent/adapters/logfiles.py +728 -0
- tabagent/adapters/openai_messages.py +672 -0
- tabagent/adapters/tau2.py +195 -0
- tabagent/agent.py +219 -0
- tabagent/api.py +1016 -0
- tabagent/artifact.py +262 -0
- tabagent/auto.py +260 -0
- tabagent/baselines.py +62 -0
- tabagent/cards/__init__.py +1 -0
- tabagent/cards/appworld_subagent_routing.yaml +77 -0
- tabagent/cards/llmrouterbench_model_routing.yaml +113 -0
- tabagent/cards/mind2web_element.yaml +88 -0
- tabagent/cards/mind2web_operation.yaml +55 -0
- tabagent/cards/skillsbench_skill_gating.yaml +57 -0
- tabagent/cards/skillsbench_skill_gating_enriched.yaml +81 -0
- tabagent/cards/skillsbench_skill_select.yaml +61 -0
- tabagent/cards/skillsbench_skill_select_noprior.yaml +53 -0
- tabagent/cards/tau2_domain_shortlisting.yaml +85 -0
- tabagent/cards/tau2_tool_shortlisting.yaml +58 -0
- tabagent/cli.py +851 -0
- tabagent/datasets/__init__.py +138 -0
- tabagent/datasets/_core.py +171 -0
- tabagent/datasets/appworld.py +207 -0
- tabagent/datasets/folds/appworld_subagent_routing.json +590 -0
- tabagent/datasets/folds/llmrouterbench_tau2.json +283 -0
- tabagent/datasets/folds/mind2web_element_150.json +155 -0
- tabagent/datasets/folds/mind2web_groupkfold5.json +1014 -0
- tabagent/datasets/folds/mind2web_split.json +1014 -0
- tabagent/datasets/folds/skillsbench_skill_gating.json +89 -0
- tabagent/datasets/folds/skillsbench_skill_gating_enriched.json +92 -0
- tabagent/datasets/folds/skillsbench_skill_select.json +92 -0
- tabagent/datasets/folds/tau2_agents.json +599 -0
- tabagent/datasets/folds/tau2_airline.json +29 -0
- tabagent/datasets/folds/tau2_all_domains.json +144 -0
- tabagent/datasets/folds/tau2_retail.json +59 -0
- tabagent/datasets/folds/tau2_telecom.json +66 -0
- tabagent/datasets/llmrouterbench.py +249 -0
- tabagent/datasets/manifests/llmrouterbench_tau2_dense.json +102 -0
- tabagent/datasets/manifests/mind2web_train.json +71 -0
- tabagent/datasets/manifests/skillsbench_official_selected.json +328 -0
- tabagent/datasets/manifests/tau2_official_final.json +185 -0
- tabagent/datasets/manifests/tau2_sft_final.json +17 -0
- tabagent/datasets/mind2web.py +342 -0
- tabagent/datasets/skillsbench.py +207 -0
- tabagent/datasets/tau2.py +361 -0
- tabagent/defaults.py +23 -0
- tabagent/drift.py +701 -0
- tabagent/evaluation/__init__.py +36 -0
- tabagent/evaluation/cv.py +220 -0
- tabagent/evaluation/discovery.py +320 -0
- tabagent/evaluation/metrics.py +266 -0
- tabagent/evaluation/stats.py +116 -0
- tabagent/hub.py +44 -0
- tabagent/integrations/__init__.py +1 -0
- tabagent/integrations/cuga.py +186 -0
- tabagent/integrations/openai_tools.py +232 -0
- tabagent/prometheus.py +196 -0
- tabagent/py.typed +0 -0
- tabagent/report.py +237 -0
- tabagent/scanner.py +1113 -0
- tabagent/serve.py +873 -0
- tabagent/sklearn.py +353 -0
- tabagent/tabhead.py +427 -0
- tabagent/table.py +178 -0
- tabagent/tabschema/__init__.py +22 -0
- tabagent/tabschema/agents.py +1304 -0
- tabagent/tabschema/audit.py +408 -0
- tabagent/tabschema/card.py +392 -0
- tabagent/tabschema/checks.py +307 -0
- tabagent/tabschema/compile.py +1811 -0
- tabagent/tabschema/estimate.py +326 -0
- tabagent/tabschema/features.py +114 -0
- tabagent/tabschema/fitted.py +474 -0
- tabagent/tabschema/judges.py +203 -0
- tabagent/tabschema/llm.py +908 -0
- tabagent/tabschema/orchestrator.py +1823 -0
- tabagent/tabschema/prompts/__init__.py +80 -0
- tabagent/tabschema/prompts/code_extractor.txt +93 -0
- tabagent/tabschema/prompts/code_validator.txt +39 -0
- tabagent/tabschema/prompts/feature_aggregator.txt +37 -0
- tabagent/tabschema/prompts/feature_analyzer.txt +35 -0
- tabagent/tabschema/prompts/judge_generality.txt +51 -0
- tabagent/tabschema/prompts/judge_impact.txt +52 -0
- tabagent/tabschema/prompts/judge_relevance.txt +49 -0
- tabagent/tabschema/prompts/meta_judge.txt +65 -0
- tabagent/tabschema/prompts/output_auditor.txt +34 -0
- tabagent/tabschema/prompts/tabagent_analyzer_candidates.txt +6 -0
- tabagent/tabschema/prompts/tabagent_auditor_candidates.txt +5 -0
- tabagent/tabschema/prompts/tabagent_candidate_note.txt +3 -0
- tabagent/tabschema/prompts/tabagent_extractor.txt +3 -0
- tabagent/tabschema/prompts/tabagent_extractor_candidates.txt +9 -0
- tabagent/tabschema/prompts/tabagent_validator_candidates.txt +9 -0
- tabagent/tabschema/runlog.py +626 -0
- tabagent/tabschema/runstore.py +267 -0
- tabagent/tabschema/sampling.py +143 -0
- tabagent/tabschema/sandbox.py +623 -0
- tabagent/tabschema/trajectory.py +989 -0
- tabagent/tabschema/usefulness.py +193 -0
- tabagent/tabschema/verify.py +1303 -0
- tabagent/tabschema/warm.py +1073 -0
- tabagent/tabsynth.py +344 -0
- tabagent-0.1.0.dist-info/METADATA +313 -0
- tabagent-0.1.0.dist-info/RECORD +113 -0
- tabagent-0.1.0.dist-info/WHEEL +4 -0
- tabagent-0.1.0.dist-info/entry_points.txt +2 -0
- tabagent-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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}
|