dayan 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dayan/__init__.py +77 -0
- dayan/augment/__init__.py +20 -0
- dayan/augment/data.py +423 -0
- dayan/augment/filtering.py +273 -0
- dayan/augment/pipeline.py +571 -0
- dayan/augment/prompts.py +275 -0
- dayan/augment/symbolic_backends.py +508 -0
- dayan/augment/verification.py +312 -0
- dayan/augment/workflow.py +112 -0
- dayan/cli.py +45 -0
- dayan/graph/__init__.py +23 -0
- dayan/graph/analysis.py +88 -0
- dayan/graph/functions.py +94 -0
- dayan/graph/graph.py +193 -0
- dayan/graph/instrument.py +619 -0
- dayan/graph/invoke.py +215 -0
- dayan/graph/trace.py +188 -0
- dayan/graph/utils.py +38 -0
- dayan/llm.py +202 -0
- dayan/metrics.py +328 -0
- dayan/mining.py +119 -0
- dayan/optimize.py +121 -0
- dayan-0.2.0.dist-info/METADATA +134 -0
- dayan-0.2.0.dist-info/RECORD +27 -0
- dayan-0.2.0.dist-info/WHEEL +4 -0
- dayan-0.2.0.dist-info/entry_points.txt +2 -0
- dayan-0.2.0.dist-info/licenses/LICENSE +201 -0
dayan/__init__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""DaYan (大衍): thought-graph modeling, evaluation, and enhancement.
|
|
2
|
+
|
|
3
|
+
Named after the I Ching's 大衍之数 — the formal generative procedure that
|
|
4
|
+
produces structured hexagram states — DaYan models an LLM's reasoning
|
|
5
|
+
process as data: instrument a reasoning program, get its Thought Graph,
|
|
6
|
+
evaluate the *process* (five metrics), mine thinking patterns, and feed them
|
|
7
|
+
back as prompt rules or verifiable RL rewards. An auxiliary subpackage
|
|
8
|
+
(``dayan.augment``) provides symbolically verified data augmentation.
|
|
9
|
+
|
|
10
|
+
from dayan import analysis, AnalysisOutput, query, set_backend
|
|
11
|
+
|
|
12
|
+
set_backend(my_llm_fn) # inject any LLM
|
|
13
|
+
result, thought_graph = solve() # @analysis(output_type=THOUGHT)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from dayan.graph import (
|
|
17
|
+
AnalysisOutput,
|
|
18
|
+
DataFlowGraph,
|
|
19
|
+
ProgramTrace,
|
|
20
|
+
ThoughtGraph,
|
|
21
|
+
VariableNode,
|
|
22
|
+
analysis,
|
|
23
|
+
query,
|
|
24
|
+
query_iter,
|
|
25
|
+
set_backend,
|
|
26
|
+
)
|
|
27
|
+
from dayan.llm import LLM, ChatClient, FakeLLM
|
|
28
|
+
from dayan.metrics import (
|
|
29
|
+
ThoughtSample,
|
|
30
|
+
crucial_node_judgment,
|
|
31
|
+
enumerate_paths,
|
|
32
|
+
multipath_reliability,
|
|
33
|
+
node_influence,
|
|
34
|
+
path_distribution,
|
|
35
|
+
reasoning_commonality,
|
|
36
|
+
reasoning_efficiency,
|
|
37
|
+
stepwise_consistency,
|
|
38
|
+
)
|
|
39
|
+
from dayan.mining import Pattern, contrastive_patterns, mine_frequent_patterns
|
|
40
|
+
from dayan.optimize import (
|
|
41
|
+
PatternProcessReward,
|
|
42
|
+
inject_rules_into_prompt,
|
|
43
|
+
patterns_to_prompt_rules,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.2.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AnalysisOutput",
|
|
50
|
+
"ChatClient",
|
|
51
|
+
"DataFlowGraph",
|
|
52
|
+
"FakeLLM",
|
|
53
|
+
"LLM",
|
|
54
|
+
"Pattern",
|
|
55
|
+
"PatternProcessReward",
|
|
56
|
+
"ProgramTrace",
|
|
57
|
+
"ThoughtGraph",
|
|
58
|
+
"ThoughtSample",
|
|
59
|
+
"VariableNode",
|
|
60
|
+
"__version__",
|
|
61
|
+
"analysis",
|
|
62
|
+
"contrastive_patterns",
|
|
63
|
+
"crucial_node_judgment",
|
|
64
|
+
"enumerate_paths",
|
|
65
|
+
"inject_rules_into_prompt",
|
|
66
|
+
"mine_frequent_patterns",
|
|
67
|
+
"multipath_reliability",
|
|
68
|
+
"node_influence",
|
|
69
|
+
"path_distribution",
|
|
70
|
+
"patterns_to_prompt_rules",
|
|
71
|
+
"query",
|
|
72
|
+
"query_iter",
|
|
73
|
+
"reasoning_commonality",
|
|
74
|
+
"reasoning_efficiency",
|
|
75
|
+
"set_backend",
|
|
76
|
+
"stepwise_consistency",
|
|
77
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Trusted data augmentation subpackage (Logical DA).
|
|
2
|
+
|
|
3
|
+
Attribute extraction → diversity filtering → label-flipped generation →
|
|
4
|
+
symbolic label verification (Z3 / Prover9 / rule / CSP backends) → feedback
|
|
5
|
+
reflection. Orchestration goes through any injected LLM workflow; the
|
|
6
|
+
default is ``SimpleWorkflow`` (no CrewAI dependency).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dayan.augment.pipeline import PipelineConfig, RunResult, run_dataset
|
|
10
|
+
from dayan.augment.symbolic_backends import SOLVERS, select_solver
|
|
11
|
+
from dayan.augment.workflow import SimpleWorkflow
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PipelineConfig",
|
|
15
|
+
"RunResult",
|
|
16
|
+
"SOLVERS",
|
|
17
|
+
"SimpleWorkflow",
|
|
18
|
+
"run_dataset",
|
|
19
|
+
"select_solver",
|
|
20
|
+
]
|
dayan/augment/data.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Dataset loading, schema inference, and format-preserving sample validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_OPTION_RE = re.compile(
|
|
12
|
+
r"^\s*(?:\(([A-Za-z0-9]{1,3})\)|([A-Za-z0-9]{1,3})[).:])\s*(.*?)\s*$"
|
|
13
|
+
)
|
|
14
|
+
_SPACE_RE = re.compile(r"\s+")
|
|
15
|
+
_ALLOWED_SUFFIXES = {".json", ".jsonl"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DatasetError(ValueError):
|
|
19
|
+
"""The input cannot be used as a labeled reasoning dataset."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Dataset:
|
|
24
|
+
path: Path
|
|
25
|
+
rows: list[dict[str, Any]]
|
|
26
|
+
source_format: str # "json" or "jsonl"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Schema:
|
|
31
|
+
kind: str # "nli", "qa", or "text"
|
|
32
|
+
text_fields: tuple[str, ...]
|
|
33
|
+
label_field: str
|
|
34
|
+
observed_labels: tuple[str, ...]
|
|
35
|
+
dataset_hint: str
|
|
36
|
+
|
|
37
|
+
def label_space(self, row: dict[str, Any]) -> tuple[str, ...]:
|
|
38
|
+
if self.kind == "qa" and isinstance(row.get("options"), list):
|
|
39
|
+
options = option_map(row["options"])
|
|
40
|
+
if options:
|
|
41
|
+
return tuple(options)
|
|
42
|
+
|
|
43
|
+
if self.kind == "nli":
|
|
44
|
+
hint = self.dataset_hint.upper()
|
|
45
|
+
observed = {canonical_relation(value) for value in self.observed_labels}
|
|
46
|
+
if hint == "CB" or {"contradiction", "neutral"} & observed:
|
|
47
|
+
return ("entailment", "contradiction", "neutral")
|
|
48
|
+
if hint == "RTE" or "not_entailment" in observed:
|
|
49
|
+
return ("entailment", "not_entailment")
|
|
50
|
+
|
|
51
|
+
if self.kind == "qa":
|
|
52
|
+
labels = tuple(str(value) for value in self.observed_labels)
|
|
53
|
+
if len(labels) > 8 or any(len(label) > 40 for label in labels):
|
|
54
|
+
return (str(row[self.label_field]),)
|
|
55
|
+
return labels
|
|
56
|
+
|
|
57
|
+
return self.observed_labels
|
|
58
|
+
|
|
59
|
+
def resolve_label(self, prediction: Any, row: dict[str, Any]) -> str | None:
|
|
60
|
+
if prediction is None:
|
|
61
|
+
return None
|
|
62
|
+
value = str(prediction).strip()
|
|
63
|
+
if not value:
|
|
64
|
+
return None
|
|
65
|
+
labels = self.label_space(row)
|
|
66
|
+
if self.kind == "qa" and isinstance(row.get("options"), list):
|
|
67
|
+
options = option_map(row["options"])
|
|
68
|
+
upper = value.upper()
|
|
69
|
+
if upper in options:
|
|
70
|
+
return upper
|
|
71
|
+
match = _OPTION_RE.match(value)
|
|
72
|
+
if match:
|
|
73
|
+
key = (match.group(1) or match.group(2)).upper()
|
|
74
|
+
if key in options:
|
|
75
|
+
return key
|
|
76
|
+
for key, text in options.items():
|
|
77
|
+
if value.casefold() == text.casefold():
|
|
78
|
+
return key
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
if self.kind == "nli":
|
|
82
|
+
relation = canonical_relation(value)
|
|
83
|
+
allowed = {canonical_relation(label): label for label in labels}
|
|
84
|
+
# Index the entailment-form of every label too: datasets labeled
|
|
85
|
+
# plain "True"/"False" must resolve solver relations
|
|
86
|
+
# "entailment"/"contradiction" back to them (Logical DA z3 fix).
|
|
87
|
+
for label in labels:
|
|
88
|
+
canon = canonical_relation(label)
|
|
89
|
+
mapped = {
|
|
90
|
+
"true": "entailment",
|
|
91
|
+
"entailed": "entailment",
|
|
92
|
+
"false": "contradiction",
|
|
93
|
+
"contradicted": "contradiction",
|
|
94
|
+
}.get(canon)
|
|
95
|
+
if mapped:
|
|
96
|
+
allowed.setdefault(mapped, label)
|
|
97
|
+
if relation in allowed:
|
|
98
|
+
return allowed[relation]
|
|
99
|
+
relation = {
|
|
100
|
+
"true": "entailment",
|
|
101
|
+
"entailed": "entailment",
|
|
102
|
+
"false": "contradiction",
|
|
103
|
+
"contradicted": "contradiction",
|
|
104
|
+
}.get(relation, relation)
|
|
105
|
+
if relation in allowed:
|
|
106
|
+
return allowed[relation]
|
|
107
|
+
if relation in {"contradiction", "neutral"} and "not_entailment" in allowed:
|
|
108
|
+
return allowed["not_entailment"]
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
for label in labels:
|
|
112
|
+
if value.casefold() == str(label).casefold():
|
|
113
|
+
return label
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def truth_label(self, truth: str, row: dict[str, Any]) -> str | None:
|
|
117
|
+
if truth not in {"true", "false", "unknown"}:
|
|
118
|
+
return None
|
|
119
|
+
if self.kind == "nli":
|
|
120
|
+
desired = {
|
|
121
|
+
"true": "entailment",
|
|
122
|
+
"false": "contradiction",
|
|
123
|
+
"unknown": "neutral",
|
|
124
|
+
}[truth]
|
|
125
|
+
return self.resolve_label(desired, row)
|
|
126
|
+
if self.kind == "qa" and isinstance(row.get("options"), list):
|
|
127
|
+
aliases = {
|
|
128
|
+
"true": {"true", "yes", "entailed"},
|
|
129
|
+
"false": {"false", "no", "contradicted"},
|
|
130
|
+
"unknown": {"unknown", "uncertain", "undetermined", "not enough information", "cannot be determined"},
|
|
131
|
+
}[truth]
|
|
132
|
+
for key, text in option_map(row["options"]).items():
|
|
133
|
+
normalized = _SPACE_RE.sub(" ", text.strip().lower())
|
|
134
|
+
if normalized in aliases:
|
|
135
|
+
return key
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def canonical_relation(value: Any) -> str:
|
|
140
|
+
key = re.sub(r"[^a-z0-9]+", "_", str(value).strip().lower()).strip("_")
|
|
141
|
+
return {
|
|
142
|
+
"no_entailment": "not_entailment",
|
|
143
|
+
"non_entailment": "not_entailment",
|
|
144
|
+
"not_entailed": "not_entailment",
|
|
145
|
+
"contradictory": "contradiction",
|
|
146
|
+
"unknown": "neutral",
|
|
147
|
+
"uncertain": "neutral",
|
|
148
|
+
}.get(key, key)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def option_map(options: list[Any]) -> dict[str, str]:
|
|
152
|
+
result: dict[str, str] = {}
|
|
153
|
+
for option in options:
|
|
154
|
+
if not isinstance(option, str):
|
|
155
|
+
return {}
|
|
156
|
+
match = _OPTION_RE.match(option)
|
|
157
|
+
if not match:
|
|
158
|
+
return {}
|
|
159
|
+
key = (match.group(1) or match.group(2)).upper()
|
|
160
|
+
if key in result or not match.group(3).strip():
|
|
161
|
+
return {}
|
|
162
|
+
result[key] = match.group(3).strip()
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def discover_inputs(input_path: Path) -> list[Path]:
|
|
167
|
+
path = input_path.expanduser().resolve()
|
|
168
|
+
if path.is_file():
|
|
169
|
+
if path.suffix.lower() not in _ALLOWED_SUFFIXES:
|
|
170
|
+
raise DatasetError(f"Unsupported input extension: {path}")
|
|
171
|
+
return [path]
|
|
172
|
+
if not path.is_dir():
|
|
173
|
+
raise DatasetError(f"Input does not exist: {path}")
|
|
174
|
+
|
|
175
|
+
candidates = sorted(
|
|
176
|
+
candidate for candidate in path.rglob("*")
|
|
177
|
+
if candidate.is_file()
|
|
178
|
+
and candidate.suffix.lower() in _ALLOWED_SUFFIXES
|
|
179
|
+
and not any(part.startswith(".") for part in candidate.relative_to(path).parts)
|
|
180
|
+
)
|
|
181
|
+
training = [
|
|
182
|
+
candidate for candidate in candidates
|
|
183
|
+
if candidate.stem.lower() == "train"
|
|
184
|
+
or candidate.stem.lower().startswith("train_")
|
|
185
|
+
]
|
|
186
|
+
if training:
|
|
187
|
+
# Some legacy folders contain the same JSON array twice, as train.json
|
|
188
|
+
# and train.jsonl. Prefer the clearly named JSON file in directory mode.
|
|
189
|
+
chosen: dict[tuple[Path, str], Path] = {}
|
|
190
|
+
for candidate in training:
|
|
191
|
+
key = (candidate.parent, candidate.stem.lower())
|
|
192
|
+
if key not in chosen or candidate.suffix.lower() == ".json":
|
|
193
|
+
chosen[key] = candidate
|
|
194
|
+
return sorted(chosen.values())
|
|
195
|
+
if len(candidates) == 1:
|
|
196
|
+
return candidates
|
|
197
|
+
if not candidates:
|
|
198
|
+
raise DatasetError(f"No JSON or JSONL data files found under {path}")
|
|
199
|
+
raise DatasetError(
|
|
200
|
+
f"{path} contains multiple data files but no train.json/train.jsonl. "
|
|
201
|
+
"Pass one file explicitly to avoid augmenting evaluation data."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_dataset(path: Path) -> Dataset:
|
|
206
|
+
raw = path.read_text(encoding="utf-8-sig")
|
|
207
|
+
if not raw.strip():
|
|
208
|
+
raise DatasetError(f"Input is empty: {path}")
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
parsed = json.loads(raw)
|
|
212
|
+
except json.JSONDecodeError:
|
|
213
|
+
rows: list[Any] = []
|
|
214
|
+
for line_number, line in enumerate(raw.splitlines(), start=1):
|
|
215
|
+
if not line.strip():
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
rows.append(json.loads(line))
|
|
219
|
+
except json.JSONDecodeError as exc:
|
|
220
|
+
raise DatasetError(
|
|
221
|
+
f"{path}:{line_number}: invalid JSONL: {exc.msg}"
|
|
222
|
+
) from exc
|
|
223
|
+
source_format = "jsonl"
|
|
224
|
+
else:
|
|
225
|
+
if isinstance(parsed, list):
|
|
226
|
+
rows = parsed
|
|
227
|
+
elif isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
|
|
228
|
+
rows = parsed["data"]
|
|
229
|
+
elif isinstance(parsed, dict):
|
|
230
|
+
rows = [parsed]
|
|
231
|
+
else:
|
|
232
|
+
raise DatasetError(f"{path}: top-level JSON must be an object or array")
|
|
233
|
+
source_format = (
|
|
234
|
+
"jsonl"
|
|
235
|
+
if isinstance(parsed, dict)
|
|
236
|
+
and not isinstance(parsed.get("data"), list)
|
|
237
|
+
and path.suffix.lower() == ".jsonl"
|
|
238
|
+
else "json"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not rows:
|
|
242
|
+
raise DatasetError(f"Input contains no records: {path}")
|
|
243
|
+
for index, row in enumerate(rows):
|
|
244
|
+
if not isinstance(row, dict):
|
|
245
|
+
raise DatasetError(f"{path}: record {index} is not a JSON object")
|
|
246
|
+
return Dataset(path=path, rows=rows, source_format=source_format)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _dataset_hint(dataset: Dataset) -> str:
|
|
250
|
+
"""Keep automatic solver routing when an original dataset file is moved."""
|
|
251
|
+
parent = dataset.path.parent.name
|
|
252
|
+
known = {
|
|
253
|
+
"folio": "FOLIO",
|
|
254
|
+
"proofwriter": "ProofWriter",
|
|
255
|
+
"prontoqa": "ProntoQA",
|
|
256
|
+
"logicaldeduction": "LogicalDeduction",
|
|
257
|
+
"arlsat": "AR-LSAT",
|
|
258
|
+
"cb": "CB",
|
|
259
|
+
"rte": "RTE",
|
|
260
|
+
}
|
|
261
|
+
normalized = re.sub(r"[^a-z0-9]", "", parent.lower())
|
|
262
|
+
if normalized in known:
|
|
263
|
+
return known[normalized]
|
|
264
|
+
identifier = str(dataset.rows[0].get("id", ""))
|
|
265
|
+
for prefix, hint in (
|
|
266
|
+
(r"^folio[_-]", "FOLIO"),
|
|
267
|
+
(r"^proofwriter[_-]", "ProofWriter"),
|
|
268
|
+
(r"^prontoqa[_-]", "ProntoQA"),
|
|
269
|
+
(r"^logical[_-]?deduction[_-]", "LogicalDeduction"),
|
|
270
|
+
(r"^ar[_-]?lsat[_-]", "AR-LSAT"),
|
|
271
|
+
):
|
|
272
|
+
if re.match(prefix, identifier, re.IGNORECASE):
|
|
273
|
+
return hint
|
|
274
|
+
return parent
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def infer_schema(dataset: Dataset) -> Schema:
|
|
278
|
+
first = dataset.rows[0]
|
|
279
|
+
if {"premise", "hypothesis", "label"} <= first.keys():
|
|
280
|
+
kind, fields, label_field = "nli", ("premise", "hypothesis"), "label"
|
|
281
|
+
elif {"context", "question", "answer"} <= first.keys():
|
|
282
|
+
kind, fields, label_field = "qa", ("context", "question"), "answer"
|
|
283
|
+
elif {"text", "label"} <= first.keys():
|
|
284
|
+
kind, fields, label_field = "text", ("text",), "label"
|
|
285
|
+
else:
|
|
286
|
+
raise DatasetError(
|
|
287
|
+
f"{dataset.path}: records need premise/hypothesis/label, "
|
|
288
|
+
"context/question/answer, or text/label"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
seen: list[str] = []
|
|
292
|
+
for index, row in enumerate(dataset.rows):
|
|
293
|
+
for field in (*fields, label_field):
|
|
294
|
+
if field not in row:
|
|
295
|
+
raise DatasetError(f"{dataset.path}: record {index} lacks {field!r}")
|
|
296
|
+
if not all(isinstance(row[field], str) and row[field].strip() for field in fields):
|
|
297
|
+
raise DatasetError(f"{dataset.path}: record {index} has an empty text field")
|
|
298
|
+
if row[label_field] is None or str(row[label_field]).strip() == "":
|
|
299
|
+
raise DatasetError(f"{dataset.path}: record {index} has an empty label")
|
|
300
|
+
if "options" in row and row["options"] is not None:
|
|
301
|
+
if not isinstance(row["options"], list) or not option_map(row["options"]):
|
|
302
|
+
raise DatasetError(
|
|
303
|
+
f"{dataset.path}: record {index} has invalid labeled options"
|
|
304
|
+
)
|
|
305
|
+
answer = str(row[label_field]).strip().upper()
|
|
306
|
+
if kind == "qa" and answer not in option_map(row["options"]):
|
|
307
|
+
raise DatasetError(
|
|
308
|
+
f"{dataset.path}: record {index} answer {answer!r} "
|
|
309
|
+
"is not an option key"
|
|
310
|
+
)
|
|
311
|
+
label = str(row[label_field])
|
|
312
|
+
if label not in seen:
|
|
313
|
+
seen.append(label)
|
|
314
|
+
return Schema(
|
|
315
|
+
kind=kind,
|
|
316
|
+
text_fields=fields,
|
|
317
|
+
label_field=label_field,
|
|
318
|
+
observed_labels=tuple(seen),
|
|
319
|
+
dataset_hint=_dataset_hint(dataset),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def sample_text(row: dict[str, Any], schema: Schema) -> str:
|
|
324
|
+
parts = [str(row[field]) for field in schema.text_fields]
|
|
325
|
+
if isinstance(row.get("options"), list):
|
|
326
|
+
parts.extend(str(value) for value in row["options"])
|
|
327
|
+
return "\n".join(parts)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def text_signature(row: dict[str, Any], schema: Schema) -> str:
|
|
331
|
+
return _SPACE_RE.sub(" ", sample_text(row, schema).strip()).casefold()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cast_label_like(label: str, original: Any) -> Any:
|
|
335
|
+
"""Keep numeric and Boolean label types from the source dataset."""
|
|
336
|
+
if isinstance(original, bool):
|
|
337
|
+
if label.lower() in {"true", "false"}:
|
|
338
|
+
return label.lower() == "true"
|
|
339
|
+
raise DatasetError(f"Cannot convert label {label!r} to bool")
|
|
340
|
+
if isinstance(original, int):
|
|
341
|
+
try:
|
|
342
|
+
return int(label)
|
|
343
|
+
except ValueError as exc:
|
|
344
|
+
raise DatasetError(f"Cannot convert label {label!r} to int") from exc
|
|
345
|
+
if isinstance(original, float):
|
|
346
|
+
try:
|
|
347
|
+
return float(label)
|
|
348
|
+
except ValueError as exc:
|
|
349
|
+
raise DatasetError(f"Cannot convert label {label!r} to float") from exc
|
|
350
|
+
return label
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def build_candidate(
|
|
354
|
+
source: dict[str, Any],
|
|
355
|
+
generated: Any,
|
|
356
|
+
schema: Schema,
|
|
357
|
+
label: str,
|
|
358
|
+
) -> dict[str, Any]:
|
|
359
|
+
if isinstance(generated, dict):
|
|
360
|
+
generated = generated.get("sample", generated.get("data", generated))
|
|
361
|
+
if not isinstance(generated, dict):
|
|
362
|
+
raise DatasetError("Agent returned no sample object")
|
|
363
|
+
|
|
364
|
+
candidate = dict(source)
|
|
365
|
+
for field in schema.text_fields:
|
|
366
|
+
value = generated.get(field, source[field])
|
|
367
|
+
if not isinstance(value, str) or not value.strip():
|
|
368
|
+
raise DatasetError(f"Generated {field!r} is empty or not text")
|
|
369
|
+
candidate[field] = value.strip()
|
|
370
|
+
|
|
371
|
+
if "options" in source and source["options"] is not None:
|
|
372
|
+
options = generated.get("options", source["options"])
|
|
373
|
+
if not isinstance(options, list) or len(options) != len(source["options"]):
|
|
374
|
+
raise DatasetError("Generated options changed the number of options")
|
|
375
|
+
original_keys = tuple(option_map(source["options"]))
|
|
376
|
+
generated_keys = tuple(option_map(options))
|
|
377
|
+
if not generated_keys or generated_keys != original_keys:
|
|
378
|
+
raise DatasetError("Generated options changed their keys or order")
|
|
379
|
+
candidate["options"] = options
|
|
380
|
+
elif "options" in generated:
|
|
381
|
+
raise DatasetError("Generated options were added to an option-free sample")
|
|
382
|
+
|
|
383
|
+
if text_signature(candidate, schema) == text_signature(source, schema):
|
|
384
|
+
raise DatasetError("Generated sample is identical to its source")
|
|
385
|
+
candidate[schema.label_field] = cast_label_like(label, source[schema.label_field])
|
|
386
|
+
return candidate
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def assign_identity(
|
|
390
|
+
source: dict[str, Any],
|
|
391
|
+
candidate: dict[str, Any],
|
|
392
|
+
serial: int,
|
|
393
|
+
next_numeric_idx: int | None,
|
|
394
|
+
) -> int | None:
|
|
395
|
+
if "id" in source:
|
|
396
|
+
candidate["id"] = f"{source['id']}__logical_da_{serial}"
|
|
397
|
+
if "idx" in source:
|
|
398
|
+
if isinstance(source["idx"], int) and next_numeric_idx is not None:
|
|
399
|
+
candidate["idx"] = next_numeric_idx
|
|
400
|
+
next_numeric_idx += 1
|
|
401
|
+
else:
|
|
402
|
+
candidate["idx"] = f"{source['idx']}__logical_da_{serial}"
|
|
403
|
+
return next_numeric_idx
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def next_idx(rows: list[dict[str, Any]]) -> int | None:
|
|
407
|
+
values = [row["idx"] for row in rows if isinstance(row.get("idx"), int)]
|
|
408
|
+
return max(values) + 1 if values else None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def write_dataset(path: Path, rows: list[dict[str, Any]], fmt: str) -> None:
|
|
412
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
414
|
+
with temporary.open("w", encoding="utf-8") as handle:
|
|
415
|
+
if fmt == "json":
|
|
416
|
+
json.dump(rows, handle, ensure_ascii=False, indent=2)
|
|
417
|
+
handle.write("\n")
|
|
418
|
+
elif fmt == "jsonl":
|
|
419
|
+
for row in rows:
|
|
420
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
421
|
+
else:
|
|
422
|
+
raise ValueError(f"Unknown output format: {fmt}")
|
|
423
|
+
temporary.replace(path)
|